feat: replace localStorage with Bun/Hono API backend
- server.ts: Hono server with basic auth, GET/PUT/DELETE /api/* endpoints - defaults.json: extracted board defaults from index.html - Dockerfile: containerized for Coolify deployment - index.html: all state-layer rewritten from localStorage to fetch API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
99
server.ts
Normal file
99
server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Hono } from 'hono';
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import defaults from './defaults.json';
|
||||
|
||||
const PORT = parseInt(Bun.env.PORT || '3000');
|
||||
export const DATA_DIR = Bun.env.DATA_DIR || path.join(import.meta.dir, 'data');
|
||||
const BOARDS_DIR = path.join(DATA_DIR, 'boards');
|
||||
|
||||
mkdirSync(BOARDS_DIR, { recursive: true });
|
||||
|
||||
async function atomicWrite(filePath: string, content: string) {
|
||||
const tmp = filePath + '.tmp';
|
||||
await Bun.write(tmp, content);
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (existsSync(path.join(DATA_DIR, 'meta.json'))) return;
|
||||
await atomicWrite(path.join(DATA_DIR, 'meta.json'), JSON.stringify({
|
||||
groups: defaults.groups,
|
||||
boardOrder: {},
|
||||
}, null, 2));
|
||||
await atomicWrite(path.join(DATA_DIR, 'ideas.json'), JSON.stringify({ ideas: [] }, null, 2));
|
||||
for (const [id, board] of Object.entries(defaults.boards)) {
|
||||
const meta = (defaults.boardMeta as Record<string, { group: string; color: string; ring: string }>)[id]
|
||||
|| { group: 'Privat', color: '#7c6af7', ring: '?' };
|
||||
await atomicWrite(path.join(BOARDS_DIR, `${id}.json`), JSON.stringify({ ...board, id, ...meta }, null, 2));
|
||||
}
|
||||
console.log('Bootstrap: initialisiert mit', Object.keys(defaults.boards).length, 'Boards');
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
if (Bun.env.KANBAN_USER && Bun.env.KANBAN_PASS) {
|
||||
app.use('*', basicAuth({ username: Bun.env.KANBAN_USER!, password: Bun.env.KANBAN_PASS! }));
|
||||
}
|
||||
|
||||
app.get('/api/state', async (c) => {
|
||||
const metaPath = path.join(DATA_DIR, 'meta.json');
|
||||
const ideasPath = path.join(DATA_DIR, 'ideas.json');
|
||||
const meta = existsSync(metaPath)
|
||||
? JSON.parse(await Bun.file(metaPath).text())
|
||||
: { groups: defaults.groups, boardOrder: {} };
|
||||
const ideas = existsSync(ideasPath)
|
||||
? (JSON.parse(await Bun.file(ideasPath).text())).ideas ?? []
|
||||
: [];
|
||||
const boards: Record<string, unknown> = {};
|
||||
const files = await readdir(BOARDS_DIR).catch(() => [] as string[]);
|
||||
for (const f of files.filter(f => f.endsWith('.json'))) {
|
||||
const id = f.slice(0, -5);
|
||||
boards[id] = JSON.parse(await Bun.file(path.join(BOARDS_DIR, f)).text());
|
||||
}
|
||||
return c.json({ meta, boards, ideas });
|
||||
});
|
||||
|
||||
app.put('/api/boards/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[a-z0-9_-]+$/i.test(id)) return c.json({ error: 'invalid id' }, 400);
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(BOARDS_DIR, `${id}.json`), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/boards/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const fp = path.join(BOARDS_DIR, `${id}.json`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/meta', async (c) => {
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(DATA_DIR, 'meta.json'), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/ideas', async (c) => {
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(DATA_DIR, 'ideas.json'), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use('/assets/*', serveStatic({ root: './' }));
|
||||
|
||||
app.get('*', async () => new Response(Bun.file(path.join(import.meta.dir, 'index.html')), {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
}));
|
||||
|
||||
Bun.serve({ fetch: app.fetch, port: PORT });
|
||||
console.log(`Kanban läuft auf http://localhost:${PORT}`);
|
||||
Reference in New Issue
Block a user