Add css hot reload
This commit is contained in:
parent
b93424fece
commit
1b366f8fc5
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0">
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0">
|
||||||
<title><!--$TITLE$--></title>
|
<title><!--$TITLE$--></title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
|
|
@ -27,6 +28,31 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!--$SCRIPT$-->
|
<!--$SCRIPT$-->
|
||||||
|
<script>
|
||||||
|
function connect() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const game = params.get('game') || '';
|
||||||
|
const ws = new WebSocket('css-ws?game=' + encodeURIComponent(game));
|
||||||
|
const onMessage = (e) => {
|
||||||
|
try {
|
||||||
|
const { type, css } = JSON.parse(e.data);
|
||||||
|
if (type === 'css') {
|
||||||
|
const styleTag = document.querySelector('style');
|
||||||
|
if (styleTag) styleTag.textContent = css;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
const onClose = () => {
|
||||||
|
ws.removeEventListener('message', onMessage);
|
||||||
|
ws.removeEventListener('close', onClose);
|
||||||
|
setTimeout(connect, 500);
|
||||||
|
};
|
||||||
|
ws.addEventListener('message', onMessage);
|
||||||
|
ws.addEventListener('close', onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -34,14 +34,10 @@ interface Args {
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
local?: 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();
|
async function buildBundle(game: string, production: boolean) {
|
||||||
const bundle = await Bun.build({
|
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
||||||
|
return Bun.build({
|
||||||
outdir: '/tmp',
|
outdir: '/tmp',
|
||||||
entrypoints: [path.resolve(srcDir, 'index.ts')],
|
entrypoints: [path.resolve(srcDir, 'index.ts')],
|
||||||
sourcemap: production ? 'none' : 'inline',
|
sourcemap: production ? 'none' : 'inline',
|
||||||
|
|
@ -58,6 +54,16 @@ export async function buildHTML(game: string, { production = false, mobile = fal
|
||||||
filePlugin,
|
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) {
|
if (bundle.success) {
|
||||||
const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js'));
|
const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js'));
|
||||||
|
|
@ -164,3 +170,21 @@ export async function buildHTML(game: string, { production = false, mobile = fal
|
||||||
console.error('Failed: ', !bundle.success, bundle);
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,84 @@
|
||||||
import Bun from 'bun';
|
import Bun, { type ServerWebSocket } from 'bun';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import browser from 'browser-detect';
|
import browser from 'browser-detect';
|
||||||
import { buildHTML } from './html';
|
import { buildHTML, buildCSS } from './html';
|
||||||
import { isGame } from './isGame';
|
import { isGame } from './isGame';
|
||||||
|
|
||||||
Bun.serve({
|
// ── CSS Hot Reload via WebSocket ────────────────────────────────────
|
||||||
async fetch(req) {
|
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
||||||
|
|
||||||
|
interface ClientData {
|
||||||
|
game: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of game name → set of WebSocket connections
|
||||||
|
const wsClients = new Map<string, Set<ServerWebSocket<ClientData>>>();
|
||||||
|
|
||||||
|
// Debounced watcher: on any source change, rebuild CSS only for affected games
|
||||||
|
const watcher = fs.watch(srcDir, { recursive: true });
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | 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<ClientData>({
|
||||||
|
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 url = new URL(req.url);
|
||||||
const pathname = path.basename(url.pathname);
|
const pathname = path.basename(url.pathname);
|
||||||
const gameParam = url.searchParams.get('game');
|
const gameParam = url.searchParams.get('game');
|
||||||
const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index';
|
const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index';
|
||||||
|
|
||||||
switch (pathname) {
|
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 '/':
|
case '/':
|
||||||
case 'index.html':
|
case 'index.html':
|
||||||
|
|
@ -20,7 +87,7 @@ Bun.serve({
|
||||||
const html = await buildHTML(
|
const html = await buildHTML(
|
||||||
game,
|
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',
|
mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true',
|
||||||
local: true,
|
local: true,
|
||||||
}
|
}
|
||||||
|
|
@ -42,4 +109,4 @@ Bun.serve({
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ export const Modal = ({ children, open, title, onClose, sidebar, footer, ['class
|
||||||
if (open) {
|
if (open) {
|
||||||
ref.current?.showModal();
|
ref.current?.showModal();
|
||||||
} else {
|
} else {
|
||||||
console.log(ref.current);
|
|
||||||
ref.current?.close();
|
ref.current?.close();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import styles from '../../assets/character-editor.module.css';
|
||||||
import { CharacterRole, useAppState, type Character } from "../../contexts/state";
|
import { CharacterRole, useAppState, type Character } from "../../contexts/state";
|
||||||
import LLM from "../../utils/llm";
|
import LLM from "../../utils/llm";
|
||||||
|
|
||||||
|
// TODO fix delete button size
|
||||||
|
|
||||||
export const CharacterEditor = ({ visible }: { visible: boolean }) => {
|
export const CharacterEditor = ({ visible }: { visible: boolean }) => {
|
||||||
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
|
||||||
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
|
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import styles from '../../assets/location-editor.module.css';
|
||||||
import { LocationScale, useAppState, type Location } from "../../contexts/state";
|
import { LocationScale, useAppState, type Location } from "../../contexts/state";
|
||||||
import LLM from "../../utils/llm";
|
import LLM from "../../utils/llm";
|
||||||
|
|
||||||
|
// TODO fix delete button size
|
||||||
|
|
||||||
export const LocationEditor = ({ visible }: { visible: boolean }) => {
|
export const LocationEditor = ({ visible }: { visible: boolean }) => {
|
||||||
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { useState } from "preact/hooks";
|
||||||
import styles from '../../assets/lore-editor.module.css';
|
import styles from '../../assets/lore-editor.module.css';
|
||||||
import { useAppState, type LoreEntry } from "../../contexts/state";
|
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 }) => {
|
export const LoreEditor = ({ visible }: { visible: boolean }) => {
|
||||||
const { currentWorld, currentStory, dispatch } = useAppState();
|
const { currentWorld, currentStory, dispatch } = useAppState();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue