Added a weird little chat for shits and giggles
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m9s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m9s
This commit is contained in:
parent
a6a17e8969
commit
871000c333
9 changed files with 297 additions and 44 deletions
18
deno.json
18
deno.json
|
@ -10,17 +10,8 @@
|
|||
"preview": "deno run -A main.ts",
|
||||
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": [
|
||||
"fresh",
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"**/_fresh/*"
|
||||
],
|
||||
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
||||
"exclude": ["**/_fresh/*"],
|
||||
"imports": {
|
||||
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
|
||||
|
@ -39,8 +30,5 @@
|
|||
"$std/": "https://deno.land/std@0.216.0/",
|
||||
"tailwindcss": "npm:tailwindcss@^4.1.4"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
}
|
||||
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
|
||||
}
|
||||
|
|
10
fresh.gen.ts
10
fresh.gen.ts
|
@ -5,11 +5,14 @@
|
|||
import * as $_404 from "./routes/_404.tsx";
|
||||
import * as $_app from "./routes/_app.tsx";
|
||||
import * as $_layout from "./routes/_layout.tsx";
|
||||
import * as $api_joke from "./routes/api/joke.ts";
|
||||
import * as $api_chat from "./routes/api/chat.ts";
|
||||
import * as $api_ping from "./routes/api/ping.ts";
|
||||
import * as $chat from "./routes/chat.tsx";
|
||||
import * as $index from "./routes/index.tsx";
|
||||
import * as $post_slug_ from "./routes/post/[slug].tsx";
|
||||
import * as $posts from "./routes/posts.tsx";
|
||||
import * as $projects from "./routes/projects.tsx";
|
||||
import * as $Chat from "./islands/Chat.tsx";
|
||||
import * as $NavigationBar from "./islands/NavigationBar.tsx";
|
||||
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
|
||||
import type { Manifest } from "$fresh/server.ts";
|
||||
|
@ -19,13 +22,16 @@ const manifest = {
|
|||
"./routes/_404.tsx": $_404,
|
||||
"./routes/_app.tsx": $_app,
|
||||
"./routes/_layout.tsx": $_layout,
|
||||
"./routes/api/joke.ts": $api_joke,
|
||||
"./routes/api/chat.ts": $api_chat,
|
||||
"./routes/api/ping.ts": $api_ping,
|
||||
"./routes/chat.tsx": $chat,
|
||||
"./routes/index.tsx": $index,
|
||||
"./routes/post/[slug].tsx": $post_slug_,
|
||||
"./routes/posts.tsx": $posts,
|
||||
"./routes/projects.tsx": $projects,
|
||||
},
|
||||
islands: {
|
||||
"./islands/Chat.tsx": $Chat,
|
||||
"./islands/NavigationBar.tsx": $NavigationBar,
|
||||
"./islands/ScrollUpButton.tsx": $ScrollUpButton,
|
||||
},
|
||||
|
|
155
islands/Chat.tsx
Normal file
155
islands/Chat.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
interface ChatMessage {
|
||||
text: string;
|
||||
sender: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) {
|
||||
const randomNum = Math.floor(Math.random() * 10000);
|
||||
setUsername(`User${randomNum}`);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://${globalThis.location.host}/api/chat`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to chat");
|
||||
setIsConnected(true);
|
||||
setSocket(ws);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "user_count") {
|
||||
setUserCount(data.count);
|
||||
} else {
|
||||
setMessages((prev) => [...prev, data]);
|
||||
|
||||
const chatBox = document.getElementById("chat-messages");
|
||||
if (chatBox) {
|
||||
setTimeout(() => {
|
||||
chatBox.scrollTop = chatBox.scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error processing message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("Disconnected from chat");
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMessage = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !socket) return;
|
||||
|
||||
const messageData = {
|
||||
text: newMessage,
|
||||
sender: username,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
socket.send(JSON.stringify(messageData));
|
||||
setNewMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="w-full max-w-4xl mx-auto bg-dark rounded-lg shadow-lg overflow-hidden border border-gray-800">
|
||||
<div class="p-4 bg-secondary text-white">
|
||||
<h2 class="text-xl font-bold">Live Chat</h2>
|
||||
<p class="text-sm opacity-80">
|
||||
{isConnected
|
||||
? `${userCount} online • Messages are not saved`
|
||||
: "Connecting..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="chat-messages"
|
||||
class="p-4 h-96 overflow-y-auto bg-dark text-gray-300"
|
||||
>
|
||||
{messages.length === 0
|
||||
? (
|
||||
<p class="text-center text-gray-500 py-8">
|
||||
No messages yet. Be the first to chat!
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
class={`mb-3 max-w-md ${
|
||||
msg.sender === username ? "ml-auto" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`px-4 py-2 rounded-lg ${
|
||||
msg.sender === username
|
||||
? "bg-secondary text-white rounded-br-none"
|
||||
: "bg-gray-800 text-gray-200 rounded-bl-none"
|
||||
}`}
|
||||
>
|
||||
<div class="flex justify-between items-baseline mb-1">
|
||||
<span class="font-bold text-sm">
|
||||
{msg.sender === username ? "You" : msg.sender}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p>{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendMessage} class="p-4 border-t border-gray-800">
|
||||
<div class="flex">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.currentTarget.value)}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 px-4 py-3 bg-gray-800 text-white rounded-l-lg border-0 focus:outline-none focus:ring-2 focus:ring-secondary placeholder-gray-500"
|
||||
disabled={!isConnected}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-secondary text-white px-8 py-3 rounded-r-lg hover:bg-opacity-90 focus:outline-none focus:ring-2 focus:ring-secondary font-medium"
|
||||
disabled={!isConnected || !newMessage.trim()}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
You are chatting as{" "}
|
||||
<span class="font-medium text-gray-400">{username}</span>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
// islands/NavigationBar.tsx
|
||||
import { useComputed, useSignal } from "@preact/signals";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { LuCodeXml, LuHouse, LuNotebookPen } from "@preact-icons/lu";
|
||||
import {
|
||||
LuCodeXml,
|
||||
LuHouse,
|
||||
LuMessageCircle,
|
||||
LuNotebookPen,
|
||||
} from "@preact-icons/lu";
|
||||
|
||||
interface NavigationBarProps {
|
||||
currentPath: string;
|
||||
|
@ -57,6 +62,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="/posts"
|
||||
|
@ -67,6 +73,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="/projects"
|
||||
|
@ -77,6 +84,17 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="/chat"
|
||||
class={currentPath.startsWith("/chat") ? "menu-active" : ""}
|
||||
>
|
||||
<div class="tooltip" data-tip="Chat">
|
||||
<LuMessageCircle class="text-xl" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
|
85
routes/api/chat.ts
Normal file
85
routes/api/chat.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { FreshContext } from "$fresh/server.ts";
|
||||
|
||||
const chatConnections = new Set<WebSocket>();
|
||||
|
||||
// HTML sanitization
|
||||
function sanitizeText(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Process and sanitize message object
|
||||
function processChatMessage(message: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
|
||||
if (typeof parsed.text === "string") {
|
||||
parsed.text = sanitizeText(parsed.text.trim().slice(0, 2000));
|
||||
}
|
||||
|
||||
if (typeof parsed.sender === "string") {
|
||||
parsed.sender = sanitizeText(parsed.sender.trim().slice(0, 50));
|
||||
}
|
||||
|
||||
if (!parsed.timestamp) {
|
||||
parsed.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
} catch (error) {
|
||||
console.error("Invalid message format:", error);
|
||||
return JSON.stringify({
|
||||
text: "Error: Invalid message format",
|
||||
sender: "System",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast current user count to all clients
|
||||
function broadcastUserCount() {
|
||||
const count = chatConnections.size;
|
||||
const message = JSON.stringify({
|
||||
type: "user_count",
|
||||
count: count,
|
||||
});
|
||||
|
||||
for (const client of chatConnections) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handler = (req: Request, _ctx: FreshContext): Response => {
|
||||
const { socket, response } = Deno.upgradeWebSocket(req);
|
||||
|
||||
socket.onopen = () => {
|
||||
chatConnections.add(socket);
|
||||
console.log(`New connection: ${chatConnections.size} users connected`);
|
||||
broadcastUserCount();
|
||||
};
|
||||
|
||||
// Handle messages
|
||||
socket.onmessage = (event) => {
|
||||
const sanitizedMessage = processChatMessage(event.data);
|
||||
|
||||
for (const client of chatConnections) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(sanitizedMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
chatConnections.delete(socket);
|
||||
console.log(`Connection closed: ${chatConnections.size} users connected`);
|
||||
broadcastUserCount();
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
import { FreshContext } from "$fresh/server.ts";
|
||||
|
||||
// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
|
||||
const JOKES = [
|
||||
"Why do Java developers often wear glasses? They can't C#.",
|
||||
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
|
||||
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
|
||||
"I love pressing the F5 key. It's refreshing.",
|
||||
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
|
||||
"There are 10 types of people in the world. Those who understand binary and those who don't.",
|
||||
"Why are assembly programmers often wet? They work below C level.",
|
||||
"My favourite computer based band is the Black IPs.",
|
||||
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
|
||||
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
|
||||
];
|
||||
|
||||
export const handler = (_req: Request, _ctx: FreshContext): Response => {
|
||||
const randomIndex = Math.floor(Math.random() * JOKES.length);
|
||||
const body = JOKES[randomIndex];
|
||||
return new Response(body);
|
||||
};
|
5
routes/api/ping.ts
Normal file
5
routes/api/ping.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { FreshContext } from "$fresh/server.ts";
|
||||
|
||||
export const handler = (_req: Request, _ctx: FreshContext): Response => {
|
||||
return new Response("pong");
|
||||
};
|
20
routes/chat.tsx
Normal file
20
routes/chat.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Chat from "../islands/Chat.tsx";
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<div class="min-h-screen p-4 sm:p-8">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-secondary mb-6 sm:mb-8 text-center">
|
||||
Chat Room <div className="badge badge-dash badge-primary">Demo</div>
|
||||
</h1>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<Chat />
|
||||
</div>
|
||||
<div class="mt-8 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
This is an ephemeral chat room. Messages are only visible to users
|
||||
currently online and aren't stored after you leave.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { CSS, render } from "@deno/gfm";
|
||||
import { Head } from "$fresh/runtime.ts";
|
||||
import { render } from "@deno/gfm";
|
||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||
|
||||
import { getPost, Post } from "../../lib/posts.ts";
|
||||
|
@ -21,9 +20,6 @@ export default function PostPage(props: PageProps<Post>) {
|
|||
const post = props.data;
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||||
</Head>
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="p-4 md:p-8">
|
||||
|
@ -73,6 +69,7 @@ export default function PostPage(props: PageProps<Post>) {
|
|||
class="max-w-none prose"
|
||||
data-color-mode="dark"
|
||||
data-dark-theme="dark"
|
||||
// deno-lint-ignore react-no-danger
|
||||
dangerouslySetInnerHTML={{ __html: render(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue