Phase 3: Express backend, SQLite persistence, auto-save

- server/db.ts: sql.js with migration system (hex_maps, hexes, hex_features)
- server/routes/maps.ts: CRUD for hex maps
- server/routes/hexes.ts: Bulk hex upsert, region load, sparse storage
- server/index.ts: Express 5, CORS, tile serving, SPA fallback
- src/data/api-client.ts: Frontend HTTP client for all API endpoints
- src/main.ts: Auto-save with 1s debounce, load map state on startup
- Port 3002 (Kiepenkerl uses 3001)
- Graceful fallback when API unavailable (works without server too)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 10:45:37 +00:00
parent 0e2903b789
commit 367ba8af07
8 changed files with 523 additions and 6 deletions

85
server/routes/maps.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Router } from 'express';
import { getDb, saveDb } from '../db.js';
const router = Router();
// List all maps
router.get('/', (_req, res) => {
const db = getDb();
const rows = db.exec('SELECT * FROM hex_maps ORDER BY updated_at DESC');
if (rows.length === 0) {
res.json([]);
return;
}
const maps = rows[0].values.map(row => rowToMap(rows[0].columns, row));
res.json(maps);
});
// Get single map
router.get('/:id', (req, res) => {
const db = getDb();
const rows = db.exec('SELECT * FROM hex_maps WHERE id = ?', [req.params.id]);
if (rows.length === 0 || rows[0].values.length === 0) {
res.status(404).json({ error: 'Map not found' });
return;
}
res.json(rowToMap(rows[0].columns, rows[0].values[0]));
});
// Create map
router.post('/', (req, res) => {
const db = getDb();
const { name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y } = req.body;
db.run(
`INSERT INTO hex_maps (name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name ?? 'Untitled', image_width ?? 8000, image_height ?? 12000,
tile_url ?? '/tiles/{z}/{x}/{y}.jpg', min_zoom ?? 0, max_zoom ?? 6,
hex_size ?? 48, origin_x ?? 0, origin_y ?? 0],
);
const idRows = db.exec('SELECT last_insert_rowid()');
const id = idRows[0].values[0][0];
saveDb();
res.status(201).json({ id });
});
// Update map
router.put('/:id', (req, res) => {
const db = getDb();
const { name, hex_size, origin_x, origin_y } = req.body;
const sets: string[] = [];
const params: any[] = [];
if (name !== undefined) { sets.push('name = ?'); params.push(name); }
if (hex_size !== undefined) { sets.push('hex_size = ?'); params.push(hex_size); }
if (origin_x !== undefined) { sets.push('origin_x = ?'); params.push(origin_x); }
if (origin_y !== undefined) { sets.push('origin_y = ?'); params.push(origin_y); }
if (sets.length === 0) {
res.status(400).json({ error: 'No fields to update' });
return;
}
sets.push("updated_at = datetime('now')");
params.push(req.params.id);
db.run(`UPDATE hex_maps SET ${sets.join(', ')} WHERE id = ?`, params);
saveDb();
res.json({ ok: true });
});
// Delete map
router.delete('/:id', (req, res) => {
const db = getDb();
db.run('DELETE FROM hex_maps WHERE id = ?', [req.params.id]);
saveDb();
res.json({ ok: true });
});
function rowToMap(columns: string[], values: any[]) {
const obj: any = {};
columns.forEach((col, i) => { obj[col] = values[i]; });
return obj;
}
export default router;