Files
kanban/server.ts
Robin Choice 4f5e16a286 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>
2026-05-20 19:13:13 +02:00

100 lines
3.7 KiB
TypeScript

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}`);