Added a weird little chat for shits and giggles
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m9s

This commit is contained in:
Atridad Lahiji 2025-04-26 01:34:49 -06:00
parent a6a17e8969
commit 871000c333
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
9 changed files with 297 additions and 44 deletions

View file

@ -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" }
}

View file

@ -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
View 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>
);
}

View file

@ -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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 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;
};

View file

@ -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
View 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
View 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>
);
}

View file

@ -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>