feat: admin page with dynamic converter loading

Implements full admin interface for managing units and groups.
Migrates converter from static imports to server-side data loading.

- Switch adapter-static → adapter-node
- Add bcryptjs session auth with HMAC-signed cookies
- Add data/units.json and data/config.json data layer
- Add atomic file writes via temp-file rename
- Add public GET /api/units endpoint
- Add auth-gated admin CRUD API for units and groups
- Add two-panel admin UI with group tree and edit forms
- Add YOLO mode toggle for cross-group conversions
- Add visual group dividers in converter results grid
- Update ResultItem type for nullable convertedValue and isYolo flag
- Group deletion supports reassign/orphan with toBase recalculation

Rollback point: 067fd44

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Falkan
2026-03-17 13:55:11 -04:00
parent 067fd445ef
commit d1f51c141e
27 changed files with 1915 additions and 81 deletions

View File

@@ -0,0 +1,111 @@
import { json } from '@sveltejs/kit';
import { loadData, saveData, loadConfig } from '$lib/server/data';
import { requireAuth } from '$lib/server/auth';
import type { RequestHandler } from './$types';
import type { Group } from '$lib/types';
function auth(request: Request) {
return requireAuth(request, loadConfig());
}
export const PUT: RequestHandler = async ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params;
const body = await request.json().catch(() => null);
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
const data = loadData();
const gIdx = data.groups.findIndex((g) => g.id === id);
if (gIdx === -1) return json({ error: 'Group not found' }, { status: 404 });
const patch = body as Partial<Group>;
const currentGroup = data.groups[gIdx];
// Special case: changing baseUnitId requires recalculating all unit toBase values
if (patch.baseUnitId !== undefined && patch.baseUnitId !== currentGroup.baseUnitId) {
const newBaseId = patch.baseUnitId;
const newBaseUnit = data.units.find((u) => u.id === newBaseId && u.group === id);
if (!newBaseUnit) {
return json({ error: `baseUnitId '${newBaseId}' not found in this group` }, { status: 400 });
}
const X = newBaseUnit.toBase; // Current toBase of the new base unit
// Recalculate all units in this group: unit.toBase = unit.toBase / X
// Then set new base unit toBase = 1.0
for (const unit of data.units) {
if (unit.group === id) {
if (unit.id === newBaseId) {
unit.toBase = 1.0;
} else {
unit.toBase = unit.toBase / X;
}
}
}
}
const updated: Group = { ...currentGroup, ...patch };
data.groups[gIdx] = updated;
saveData(data);
return json(updated);
};
export const DELETE: RequestHandler = async ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params;
const body = await request.json().catch(() => ({}));
const { action, targetGroupId, toBaseAction } = body as {
action?: 'reassign' | 'orphan';
targetGroupId?: string;
toBaseAction?: 'recalculate' | 'reset' | 'keep';
};
if (!action || (action !== 'reassign' && action !== 'orphan')) {
return json({ error: "action must be 'reassign' or 'orphan'" }, { status: 400 });
}
const data = loadData();
const gIdx = data.groups.findIndex((g) => g.id === id);
if (gIdx === -1) return json({ error: 'Group not found' }, { status: 404 });
const fromGroup = data.groups[gIdx];
const unitsInGroup = data.units.filter((u) => u.group === id);
if (action === 'reassign') {
if (!targetGroupId) {
return json({ error: 'targetGroupId required for reassign' }, { status: 400 });
}
if (targetGroupId === id) {
return json({ error: 'Cannot reassign to the same group' }, { status: 400 });
}
const toGroup = data.groups.find((g) => g.id === targetGroupId);
if (!toGroup) {
return json({ error: `Target group '${targetGroupId}' not found` }, { status: 404 });
}
for (const unit of data.units) {
if (unit.group === id) {
if (toBaseAction === 'recalculate') {
unit.toBase = (unit.toBase * fromGroup.toUniversal) / toGroup.toUniversal;
} else if (toBaseAction === 'reset') {
unit.toBase = 1.0;
}
// 'keep' or default: no change to toBase
unit.group = targetGroupId;
}
}
} else if (action === 'orphan') {
for (const unit of data.units) {
if (unit.group === id) {
unit.group = null;
}
}
}
data.groups.splice(gIdx, 1);
saveData(data);
return json({ ok: true, unitsAffected: unitsInGroup.length });
};