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:
111
src/routes/admin/api/groups/[id]/+server.ts
Normal file
111
src/routes/admin/api/groups/[id]/+server.ts
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user