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",
|
"preview": "deno run -A main.ts",
|
||||||
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
||||||
"rules": {
|
"exclude": ["**/_fresh/*"],
|
||||||
"tags": [
|
|
||||||
"fresh",
|
|
||||||
"recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"**/_fresh/*"
|
|
||||||
],
|
|
||||||
"imports": {
|
"imports": {
|
||||||
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||||
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
|
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
|
||||||
|
@ -39,8 +30,5 @@
|
||||||
"$std/": "https://deno.land/std@0.216.0/",
|
"$std/": "https://deno.land/std@0.216.0/",
|
||||||
"tailwindcss": "npm:tailwindcss@^4.1.4"
|
"tailwindcss": "npm:tailwindcss@^4.1.4"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
|
||||||
"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 $_404 from "./routes/_404.tsx";
|
||||||
import * as $_app from "./routes/_app.tsx";
|
import * as $_app from "./routes/_app.tsx";
|
||||||
import * as $_layout from "./routes/_layout.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 $index from "./routes/index.tsx";
|
||||||
import * as $post_slug_ from "./routes/post/[slug].tsx";
|
import * as $post_slug_ from "./routes/post/[slug].tsx";
|
||||||
import * as $posts from "./routes/posts.tsx";
|
import * as $posts from "./routes/posts.tsx";
|
||||||
import * as $projects from "./routes/projects.tsx";
|
import * as $projects from "./routes/projects.tsx";
|
||||||
|
import * as $Chat from "./islands/Chat.tsx";
|
||||||
import * as $NavigationBar from "./islands/NavigationBar.tsx";
|
import * as $NavigationBar from "./islands/NavigationBar.tsx";
|
||||||
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
|
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
|
||||||
import type { Manifest } from "$fresh/server.ts";
|
import type { Manifest } from "$fresh/server.ts";
|
||||||
|
@ -19,13 +22,16 @@ const manifest = {
|
||||||
"./routes/_404.tsx": $_404,
|
"./routes/_404.tsx": $_404,
|
||||||
"./routes/_app.tsx": $_app,
|
"./routes/_app.tsx": $_app,
|
||||||
"./routes/_layout.tsx": $_layout,
|
"./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/index.tsx": $index,
|
||||||
"./routes/post/[slug].tsx": $post_slug_,
|
"./routes/post/[slug].tsx": $post_slug_,
|
||||||
"./routes/posts.tsx": $posts,
|
"./routes/posts.tsx": $posts,
|
||||||
"./routes/projects.tsx": $projects,
|
"./routes/projects.tsx": $projects,
|
||||||
},
|
},
|
||||||
islands: {
|
islands: {
|
||||||
|
"./islands/Chat.tsx": $Chat,
|
||||||
"./islands/NavigationBar.tsx": $NavigationBar,
|
"./islands/NavigationBar.tsx": $NavigationBar,
|
||||||
"./islands/ScrollUpButton.tsx": $ScrollUpButton,
|
"./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
|
// islands/NavigationBar.tsx
|
||||||
import { useComputed, useSignal } from "@preact/signals";
|
import { useComputed, useSignal } from "@preact/signals";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import { LuCodeXml, LuHouse, LuNotebookPen } from "@preact-icons/lu";
|
import {
|
||||||
|
LuCodeXml,
|
||||||
|
LuHouse,
|
||||||
|
LuMessageCircle,
|
||||||
|
LuNotebookPen,
|
||||||
|
} from "@preact-icons/lu";
|
||||||
|
|
||||||
interface NavigationBarProps {
|
interface NavigationBarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
@ -57,6 +62,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/posts"
|
href="/posts"
|
||||||
|
@ -67,6 +73,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/projects"
|
href="/projects"
|
||||||
|
@ -77,6 +84,17 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</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 { render } from "@deno/gfm";
|
||||||
import { Head } from "$fresh/runtime.ts";
|
|
||||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||||
|
|
||||||
import { getPost, Post } from "../../lib/posts.ts";
|
import { getPost, Post } from "../../lib/posts.ts";
|
||||||
|
@ -21,9 +20,6 @@ export default function PostPage(props: PageProps<Post>) {
|
||||||
const post = props.data;
|
const post = props.data;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
|
||||||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
|
||||||
</Head>
|
|
||||||
<div class="min-h-screen p-4 md:p-8">
|
<div class="min-h-screen p-4 md:p-8">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="p-4 md:p-8">
|
<div class="p-4 md:p-8">
|
||||||
|
@ -73,6 +69,7 @@ export default function PostPage(props: PageProps<Post>) {
|
||||||
class="max-w-none prose"
|
class="max-w-none prose"
|
||||||
data-color-mode="dark"
|
data-color-mode="dark"
|
||||||
data-dark-theme="dark"
|
data-dark-theme="dark"
|
||||||
|
// deno-lint-ignore react-no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: render(post.content) }}
|
dangerouslySetInnerHTML={{ __html: render(post.content) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Reference in a new issue