diff --git a/deno.json b/deno.json index 03f901a..5c18969 100644 --- a/deno.json +++ b/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" } } diff --git a/fresh.gen.ts b/fresh.gen.ts index eefedab..9e3df6d 100644 --- a/fresh.gen.ts +++ b/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, }, diff --git a/islands/Chat.tsx b/islands/Chat.tsx new file mode 100644 index 0000000..0f4ab4d --- /dev/null +++ b/islands/Chat.tsx @@ -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([]); + const [newMessage, setNewMessage] = useState(""); + const [username, setUsername] = useState(""); + const [socket, setSocket] = useState(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 ( +
+
+

Live Chat

+

+ {isConnected + ? `${userCount} online • Messages are not saved` + : "Connecting..."} +

+
+ +
+ {messages.length === 0 + ? ( +

+ No messages yet. Be the first to chat! +

+ ) + : ( + messages.map((msg, i) => ( +
+
+
+ + {msg.sender === username ? "You" : msg.sender} + + + {new Date(msg.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+

{msg.text}

+
+
+ )) + )} +
+ +
+
+ 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} + /> + +
+

+ You are chatting as{" "} + {username} +

+
+
+ ); +} diff --git a/islands/NavigationBar.tsx b/islands/NavigationBar.tsx index 9287c1b..c06b9a0 100644 --- a/islands/NavigationBar.tsx +++ b/islands/NavigationBar.tsx @@ -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) { +
  • +
  • + +
  • + +
    + +
    +
    +
  • ); diff --git a/routes/api/chat.ts b/routes/api/chat.ts new file mode 100644 index 0000000..4b1981a --- /dev/null +++ b/routes/api/chat.ts @@ -0,0 +1,85 @@ +import { FreshContext } from "$fresh/server.ts"; + +const chatConnections = new Set(); + +// HTML sanitization +function sanitizeText(text: string): string { + return text + .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; +}; diff --git a/routes/api/joke.ts b/routes/api/joke.ts deleted file mode 100644 index db17edd..0000000 --- a/routes/api/joke.ts +++ /dev/null @@ -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); -}; diff --git a/routes/api/ping.ts b/routes/api/ping.ts new file mode 100644 index 0000000..ddb1e9d --- /dev/null +++ b/routes/api/ping.ts @@ -0,0 +1,5 @@ +import { FreshContext } from "$fresh/server.ts"; + +export const handler = (_req: Request, _ctx: FreshContext): Response => { + return new Response("pong"); +}; diff --git a/routes/chat.tsx b/routes/chat.tsx new file mode 100644 index 0000000..d40f0d1 --- /dev/null +++ b/routes/chat.tsx @@ -0,0 +1,20 @@ +import Chat from "../islands/Chat.tsx"; + +export default function ChatPage() { + return ( +
    +

    + Chat Room
    Demo
    +

    +
    + +
    +
    +

    + This is an ephemeral chat room. Messages are only visible to users + currently online and aren't stored after you leave. +

    +
    +
    + ); +} diff --git a/routes/post/[slug].tsx b/routes/post/[slug].tsx index 4eb12a7..27f725f 100644 --- a/routes/post/[slug].tsx +++ b/routes/post/[slug].tsx @@ -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) { const post = props.data; return ( <> - -