diff --git a/build/assets/index-local.html b/build/assets/index-local.html index ca42d25..79535ce 100644 --- a/build/assets/index-local.html +++ b/build/assets/index-local.html @@ -3,7 +3,8 @@ - + <!--$TITLE$--> + + \ No newline at end of file diff --git a/build/html.ts b/build/html.ts index 929bf4e..d9c00f1 100644 --- a/build/html.ts +++ b/build/html.ts @@ -34,14 +34,10 @@ interface Args { mobile?: boolean; local?: boolean; } -export async function buildHTML(game: string, { production = false, mobile = false, local = false }: Args = {}) { - const assetsDir = path.resolve(import.meta.dir, 'assets'); - const srcDir = path.resolve(import.meta.dir, '..', 'src'); - const gameDir = path.resolve(srcDir, 'games', game); - const gameAssetsDir = path.resolve(gameDir, 'assets'); - const html = await Bun.file(path.resolve(assetsDir, local ? 'index-local.html' : 'index.html')).text(); - const bundle = await Bun.build({ +async function buildBundle(game: string, production: boolean) { + const srcDir = path.resolve(import.meta.dir, '..', 'src'); + return Bun.build({ outdir: '/tmp', entrypoints: [path.resolve(srcDir, 'index.ts')], sourcemap: production ? 'none' : 'inline', @@ -58,6 +54,16 @@ export async function buildHTML(game: string, { production = false, mobile = fal filePlugin, ] }); +} + +export async function buildHTML(game: string, { production = false, mobile = false, local = false }: Args = {}) { + const assetsDir = path.resolve(import.meta.dir, 'assets'); + const srcDir = path.resolve(import.meta.dir, '..', 'src'); + const gameDir = path.resolve(srcDir, 'games', game); + const gameAssetsDir = path.resolve(gameDir, 'assets'); + + const html = await Bun.file(path.resolve(assetsDir, local ? 'index-local.html' : 'index.html')).text(); + const bundle = await buildBundle(game, production); if (bundle.success) { const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js')); @@ -163,4 +169,22 @@ export async function buildHTML(game: string, { production = false, mobile = fal } else { console.error('Failed: ', !bundle.success, bundle); } +} + +/** + * Rebuild only the CSS bundle (resolves composes: directives via Bun.build). + * Used by the dev server for CSS hot reload. + */ +export async function buildCSS(game: string): Promise { + const bundle = await buildBundle(game, false); + if (bundle.success) { + let style = ''; + for (const file of bundle.outputs.filter(a => a.kind === 'asset' && a.path.endsWith('.css'))) { + style += await file.text(); + } + return style; + } else { + console.error('CSS build failed:', bundle); + return null; + } } \ No newline at end of file diff --git a/build/server.ts b/build/server.ts index 3b5badf..cba522a 100644 --- a/build/server.ts +++ b/build/server.ts @@ -1,17 +1,84 @@ -import Bun from 'bun'; +import Bun, { type ServerWebSocket } from 'bun'; import path from 'path'; +import fs from 'fs'; import browser from 'browser-detect'; -import { buildHTML } from './html'; +import { buildHTML, buildCSS } from './html'; import { isGame } from './isGame'; -Bun.serve({ - async fetch(req) { +// ── CSS Hot Reload via WebSocket ──────────────────────────────────── +const srcDir = path.resolve(import.meta.dir, '..', 'src'); + +interface ClientData { + game: string; +} + +// Map of game name → set of WebSocket connections +const wsClients = new Map>>(); + +// Debounced watcher: on any source change, rebuild CSS only for affected games +const watcher = fs.watch(srcDir, { recursive: true }); +let debounce: ReturnType | null = null; +watcher.on('change', async (_, filename) => { + if (!filename) return; + const filenameStr = filename.toString(); + const rel = filenameStr.replace(/\\/g, '/'); + const gameMatch = rel.match(/^games\/([^/]+)\//); + const changedGame = gameMatch ? gameMatch[1] : null; + + if (debounce) clearTimeout(debounce); + debounce = setTimeout(async () => { + if (changedGame && !wsClients.has(changedGame)) return; + const affectedGames = changedGame + ? [[changedGame, wsClients.get(changedGame)!] as const] + : [...wsClients.entries()]; // common/ → all games + + for (const [game, clients] of affectedGames) { + const css = await buildCSS(game); + if (css === null) continue; + const message = JSON.stringify({ type: 'css', css }); + for (const ws of clients) { + ws.send(message); + } + } + }, 50); +}); +// ───────────────────────────────────────────────────────────────────── + +Bun.serve({ + websocket: { + open(ws) { + const game = ws.data.game; + if (!wsClients.has(game)) wsClients.set(game, new Set()); + wsClients.get(game)!.add(ws); + console.log(`[css-hot] ${game} connected (${wsClients.get(game)!.size} clients)`); + }, + close(ws) { + const game = ws.data.game; + const clients = wsClients.get(game); + if (clients) { + clients.delete(ws); + if (clients.size === 0) wsClients.delete(game); + } + }, + message() { }, + }, + async fetch(req, server) { const url = new URL(req.url); const pathname = path.basename(url.pathname); const gameParam = url.searchParams.get('game'); const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index'; switch (pathname) { + case 'css-ws': { + if (!(await isGame(gameParam))) { + return new Response('Unknown game', { status: 400 }); + } + const upgraded = server.upgrade(req, { + data: { game: gameParam! } satisfies ClientData, + }); + if (upgraded) return undefined; + return new Response('Upgrade failed', { status: 500 }); + } case '': case '/': case 'index.html': @@ -20,7 +87,7 @@ Bun.serve({ const html = await buildHTML( game, { - production: url.searchParams.get('production') === 'true', // to debug production builds + production: url.searchParams.get('production') === 'true', mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true', local: true, } @@ -42,4 +109,4 @@ Bun.serve({ return new Response(null, { status: 404 }); } } -}) \ No newline at end of file +}); diff --git a/src/common/components/Modal.tsx b/src/common/components/Modal.tsx index 25ccb41..9b0927a 100644 --- a/src/common/components/Modal.tsx +++ b/src/common/components/Modal.tsx @@ -23,7 +23,6 @@ export const Modal = ({ children, open, title, onClose, sidebar, footer, ['class if (open) { ref.current?.showModal(); } else { - console.log(ref.current); ref.current?.close(); } }, [open]); diff --git a/src/games/storywriter/components/editors/character.tsx b/src/games/storywriter/components/editors/character.tsx index 9d7e014..729cf2c 100644 --- a/src/games/storywriter/components/editors/character.tsx +++ b/src/games/storywriter/components/editors/character.tsx @@ -5,6 +5,8 @@ import styles from '../../assets/character-editor.module.css'; import { CharacterRole, useAppState, type Character } from "../../contexts/state"; import LLM from "../../utils/llm"; +// TODO fix delete button size + export const CharacterEditor = ({ visible }: { visible: boolean }) => { const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState(); const [newNickname, setNewNickname] = useState>({}); diff --git a/src/games/storywriter/components/editors/location.tsx b/src/games/storywriter/components/editors/location.tsx index 4a3c09e..6e3b364 100644 --- a/src/games/storywriter/components/editors/location.tsx +++ b/src/games/storywriter/components/editors/location.tsx @@ -4,6 +4,8 @@ import styles from '../../assets/location-editor.module.css'; import { LocationScale, useAppState, type Location } from "../../contexts/state"; import LLM from "../../utils/llm"; +// TODO fix delete button size + export const LocationEditor = ({ visible }: { visible: boolean }) => { const { currentWorld, currentStory, dispatch, connection, model } = useAppState(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); diff --git a/src/games/storywriter/components/editors/lore.tsx b/src/games/storywriter/components/editors/lore.tsx index 3abd9d3..6f81bad 100644 --- a/src/games/storywriter/components/editors/lore.tsx +++ b/src/games/storywriter/components/editors/lore.tsx @@ -3,6 +3,9 @@ import { useState } from "preact/hooks"; import styles from '../../assets/lore-editor.module.css'; import { useAppState, type LoreEntry } from "../../contexts/state"; +// TODO move adding ui below title on mobile +// TODO add confirm to delete + export const LoreEditor = ({ visible }: { visible: boolean }) => { const { currentWorld, currentStory, dispatch } = useAppState(); const [editingId, setEditingId] = useState(null);