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,747 @@
<script lang="ts">
import type { Unit, Group, UnitsData } from '$lib/types';
// ── State ────────────────────────────────────────────────────────────────────
let unitsData = $state<UnitsData>({ units: [], groups: [] });
let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null);
let checkedUnitIds = $state<Set<string>>(new Set());
let moveTargetGroupId = $state<string>('');
let expandedGroups = $state<Set<string>>(new Set());
let deleteGroupTarget = $state<Group | null>(null);
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
let deleteGroupTargetId = $state<string>('');
let deleteGroupToBaseAction = $state<'recalculate' | 'reset' | 'keep'>('keep');
let formError = $state<string | null>(null);
let formSuccess = $state<string | null>(null);
// Unit form state
let unitForm = $state<Partial<Unit>>({});
let unitFormNew = $state(false);
// Group form state
let groupForm = $state<Partial<Group>>({});
let groupFormNew = $state(false);
// ── Load data on mount ───────────────────────────────────────────────────────
async function loadData() {
const res = await fetch('/admin/api/units');
if (!res.ok) return;
const units: Unit[] = await res.json();
const gres = await fetch('/admin/api/groups');
const groups: Group[] = gres.ok ? await gres.json() : [];
unitsData = { units, groups };
// Initialize expanded state
for (const g of groups) {
expandedGroups.add(g.id);
}
}
// Load on mount
$effect(() => {
loadData();
});
// ── Derived ──────────────────────────────────────────────────────────────────
let unitsByGroup = $derived.by(() => {
const map = new Map<string | null, Unit[]>();
for (const u of unitsData.units) {
const key = u.group ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(u);
}
return map;
});
function getBaseUnit(group: Group): Unit | undefined {
return unitsData.units.find((u) => u.id === group.baseUnitId);
}
// ── Selection ────────────────────────────────────────────────────────────────
function selectUnit(unit: Unit) {
selectedItem = { type: 'unit', id: unit.id };
unitForm = { ...unit };
unitFormNew = false;
groupFormNew = false;
formError = null;
formSuccess = null;
}
function selectGroup(group: Group) {
selectedItem = { type: 'group', id: group.id };
groupForm = { ...group };
groupFormNew = false;
unitFormNew = false;
formError = null;
formSuccess = null;
}
function startNewUnit(groupId?: string) {
selectedItem = null;
unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
unitFormNew = true;
groupFormNew = false;
formError = null;
formSuccess = null;
}
function startNewGroup() {
selectedItem = null;
groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
groupFormNew = true;
unitFormNew = false;
formError = null;
formSuccess = null;
}
// ── Unit form submit ─────────────────────────────────────────────────────────
async function saveUnit() {
formError = null;
formSuccess = null;
try {
let res: Response;
if (unitFormNew) {
res = await fetch('/admin/api/units', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(unitForm)
});
} else {
res = await fetch(`/admin/api/units/${selectedItem?.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(unitForm)
});
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
formError = (err as Record<string, string>).error ?? 'Save failed';
return;
}
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
if (unitFormNew) {
const created = (await res.json()) as Unit;
unitFormNew = false;
selectedItem = { type: 'unit', id: created.id ?? '' };
}
await loadData();
} catch {
formError = 'Network error';
}
}
async function deleteUnit() {
if (!selectedItem || selectedItem.type !== 'unit') return;
if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
try {
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
formError = (err as Record<string, string>).error ?? 'Delete failed';
return;
}
selectedItem = null;
unitForm = {};
await loadData();
} catch {
formError = 'Network error';
}
}
// ── Group form submit ────────────────────────────────────────────────────────
async function saveGroup() {
formError = null;
formSuccess = null;
try {
let res: Response;
if (groupFormNew) {
res = await fetch('/admin/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
});
} else {
res = await fetch(`/admin/api/groups/${selectedItem?.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
});
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
formError = (err as Record<string, string>).error ?? 'Save failed';
return;
}
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
if (groupFormNew) {
const created = (await res.json()) as Group;
groupFormNew = false;
selectedItem = { type: 'group', id: created.id ?? '' };
}
await loadData();
} catch {
formError = 'Network error';
}
}
// ── Delete group modal ───────────────────────────────────────────────────────
function openDeleteGroup(group: Group) {
deleteGroupTarget = group;
deleteGroupAction = 'reassign';
deleteGroupTargetId = '';
deleteGroupToBaseAction = 'keep';
formError = null;
}
function closeDeleteGroup() {
deleteGroupTarget = null;
}
async function confirmDeleteGroup() {
if (!deleteGroupTarget) return;
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
let body: Record<string, unknown>;
if (unitsCount === 0) {
body = { action: 'orphan' };
} else if (deleteGroupAction === 'reassign') {
body = {
action: 'reassign',
targetGroupId: deleteGroupTargetId,
toBaseAction: deleteGroupToBaseAction
};
} else {
body = { action: 'orphan' };
}
try {
const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
formError = (err as Record<string, string>).error ?? 'Delete failed';
return;
}
deleteGroupTarget = null;
selectedItem = null;
groupForm = {};
await loadData();
} catch {
formError = 'Network error';
}
}
// ── Multi-select move ────────────────────────────────────────────────────────
async function moveCheckedUnits() {
if (!moveTargetGroupId || checkedUnitIds.size === 0) return;
for (const unitId of checkedUnitIds) {
await fetch(`/admin/api/units/${unitId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group: moveTargetGroupId })
});
}
checkedUnitIds = new Set();
moveTargetGroupId = '';
await loadData();
}
function toggleCheck(unitId: string) {
const next = new Set(checkedUnitIds);
if (next.has(unitId)) next.delete(unitId);
else next.add(unitId);
checkedUnitIds = next;
}
// ── Live toBase hint ─────────────────────────────────────────────────────────
let toBaseHint = $derived.by(() => {
const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
if (!g) return null;
const base = unitsData.units.find((u) => u.id === g.baseUnitId);
const sym = unitForm.symbol ?? '?';
const tb = unitForm.toBase ?? 1;
const baseSymbol = base?.symbol ?? 'base units';
return `1 ${sym} = ${tb} ${baseSymbol}`;
});
async function logout() {
await fetch('/admin/api/auth/logout', { method: 'POST' });
window.location.href = '/admin/login';
}
</script>
<svelte:head>
<title>Humor Units — Admin</title>
</svelte:head>
<div class="admin-header">
<h2>Admin</h2>
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
</div>
{#if checkedUnitIds.size > 0}
<div class="bulk-actions">
<span>{checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected</span>
<select bind:value={moveTargetGroupId}>
<option value="">Move to group…</option>
{#each unitsData.groups as g}
<option value={g.id}>{g.label}</option>
{/each}
</select>
<button onclick={moveCheckedUnits} disabled={!moveTargetGroupId}>Move</button>
<button class="outline secondary" onclick={() => (checkedUnitIds = new Set())}>Clear</button>
</div>
{/if}
<div class="admin-panels">
<!-- Left panel: tree -->
<aside class="admin-tree">
{#each unitsData.groups as group (group.id)}
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
<div class="tree-group">
<div class="tree-group-header">
<button
class="tree-expand plain"
onclick={() => {
const next = new Set(expandedGroups);
if (next.has(group.id)) next.delete(group.id);
else next.add(group.id);
expandedGroups = next;
}}
>
{expandedGroups.has(group.id) ? '▾' : '▸'}
</button>
<button class="tree-group-label plain" onclick={() => selectGroup(group)}>
<strong>{group.label}</strong>
<small class="muted">{groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
</button>
<div class="tree-group-actions">
<button class="icon-btn plain" title="Edit group" onclick={() => selectGroup(group)}>✏️</button>
<button class="icon-btn plain" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑️</button>
</div>
</div>
{#if expandedGroups.has(group.id)}
<div class="tree-units">
{#each groupUnits as unit (unit.id)}
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
<input
type="checkbox"
checked={checkedUnitIds.has(unit.id)}
onchange={() => toggleCheck(unit.id)}
/>
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
{unit.label}
<span class="muted">({unit.symbol})</span>
{#if unit.id === group.baseUnitId}
<span class="base-badge" title="Base unit"></span>
{/if}
</button>
<button class="icon-btn plain" title="Edit" onclick={() => selectUnit(unit)}>✏️</button>
</div>
{/each}
<button class="add-unit-btn plain" onclick={() => startNewUnit(group.id)}>
+ Add Unit to {group.label}
</button>
</div>
{/if}
</div>
{/each}
<!-- Ungrouped section -->
{#if (unitsByGroup.get(null) ?? []).length > 0}
{@const orphaned = unitsByGroup.get(null) ?? []}
<div class="tree-group">
<div class="tree-group-header">
<span class="tree-group-label"><strong>Ungrouped</strong> <small class="muted">{orphaned.length}</small></span>
</div>
<div class="tree-units">
{#each orphaned as unit (unit.id)}
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
<input
type="checkbox"
checked={checkedUnitIds.has(unit.id)}
onchange={() => toggleCheck(unit.id)}
/>
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
{unit.label} <span class="muted">({unit.symbol})</span>
</button>
<button class="icon-btn plain" onclick={() => selectUnit(unit)}>✏️</button>
</div>
{/each}
</div>
</div>
{/if}
<div class="tree-footer">
<button onclick={() => startNewUnit()}>+ Add Unit</button>
<button class="outline" onclick={startNewGroup}>+ Add Group</button>
</div>
</aside>
<!-- Right panel: form -->
<section class="admin-form-panel">
{#if formError}
<p class="form-error" role="alert">{formError}</p>
{/if}
{#if formSuccess}
<p class="form-success" role="status">{formSuccess}</p>
{/if}
{#if (selectedItem?.type === 'unit' || unitFormNew)}
<!-- Unit form -->
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
<label>
ID {#if !unitFormNew}<small>(readonly)</small>{/if}
<input
type="text"
bind:value={unitForm.id}
readonly={!unitFormNew}
placeholder="e.g. my-unit"
/>
</label>
<label>
Label
<input type="text" bind:value={unitForm.label} placeholder="e.g. My Unit" />
</label>
<label>
Label (plural)
<input type="text" bind:value={unitForm.labelPlural} placeholder="e.g. My Units" />
</label>
<label>
Symbol
<input type="text" bind:value={unitForm.symbol} placeholder="e.g. mu" />
</label>
<label>
Group
<select bind:value={unitForm.group}>
<option value={null}>Ungrouped</option>
{#each unitsData.groups as g}
<option value={g.id}>{g.label}</option>
{/each}
</select>
</label>
<label>
To Base
<input type="number" bind:value={unitForm.toBase} min="0.000001" step="any" />
{#if toBaseHint}
<small class="tobase-hint">{toBaseHint}</small>
{/if}
</label>
<div class="form-actions">
<button onclick={saveUnit}>{unitFormNew ? 'Create Unit' : 'Save Unit'}</button>
{#if !unitFormNew}
<button class="outline secondary" onclick={deleteUnit}>Delete</button>
{/if}
</div>
{:else if (selectedItem?.type === 'group' || groupFormNew)}
<!-- Group form -->
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
<label>
ID {#if !groupFormNew}<small>(readonly)</small>{/if}
<input
type="text"
bind:value={groupForm.id}
readonly={!groupFormNew}
placeholder="e.g. my-group"
/>
</label>
<label>
Label
<input type="text" bind:value={groupForm.label} placeholder="e.g. My Group" />
</label>
<label>
Base Unit
<small>Changing the base unit will auto-recalculate all toBase values in this group.</small>
<select bind:value={groupForm.baseUnitId}>
<option value="">None</option>
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
<option value={u.id}>{u.label} ({u.symbol})</option>
{/each}
</select>
</label>
<label>
To Universal
<small title="Relative scale for cross-group YOLO conversions. Arbitrary — make it up.">
Relative scale for cross-group conversions.
</small>
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
</label>
<div class="form-actions">
<button onclick={saveGroup}>{groupFormNew ? 'Create Group' : 'Save Group'}</button>
{#if !groupFormNew}
<button
class="outline secondary"
onclick={() => {
const g = unitsData.groups.find((gr) => gr.id === selectedItem?.id);
if (g) openDeleteGroup(g);
}}
>Delete</button>
{/if}
</div>
{:else}
<p class="select-hint">Select a unit or group to edit</p>
{/if}
</section>
</div>
<!-- Delete group modal -->
{#if deleteGroupTarget}
{@const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={closeDeleteGroup}>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<article class="modal-card" onclick={(e) => e.stopPropagation()}>
<h3>Delete "{deleteGroupTarget.label}"</h3>
{#if unitsCount === 0}
<p>This group has no units. Delete it?</p>
<div class="form-actions">
<button
onclick={() => {
deleteGroupAction = 'orphan';
confirmDeleteGroup();
}}
>Delete Group</button>
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
</div>
{:else}
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
<fieldset>
<legend>What to do with these units?</legend>
<label>
<input type="radio" bind:group={deleteGroupAction} value="reassign" />
Reassign to another group
</label>
{#if deleteGroupAction === 'reassign'}
<select bind:value={deleteGroupTargetId} style="margin: 0.5rem 0;">
<option value="">Select target group…</option>
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
<option value={g.id}>{g.label}</option>
{/each}
</select>
<div>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values</label>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate (scale by toUniversal ratio)</label>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset to 1.0</label>
</div>
{/if}
<label>
<input type="radio" bind:group={deleteGroupAction} value="orphan" />
Delete Group, Keep Units (Orphan)
</label>
</fieldset>
<div class="form-actions">
<button
onclick={confirmDeleteGroup}
disabled={deleteGroupAction === 'reassign' && !deleteGroupTargetId}
>
{deleteGroupAction === 'reassign' ? 'Reassign & Delete Group' : 'Delete Group, Keep Units'}
</button>
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
</div>
{/if}
</article>
</div>
{/if}
<style>
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.admin-header h2 {
margin: 0;
}
.bulk-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
}
.admin-panels {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 720px) {
.admin-panels { grid-template-columns: 1fr; }
}
.admin-tree {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
padding: 0.75rem;
}
.tree-group { margin-bottom: 0.5rem; }
.tree-group-header {
display: flex;
align-items: center;
gap: 0.25rem;
}
.tree-group-label {
flex: 1;
text-align: left;
display: flex;
gap: 0.4rem;
align-items: baseline;
}
.tree-group-actions {
display: flex;
gap: 0.25rem;
}
.tree-units {
margin-left: 1.5rem;
margin-top: 0.25rem;
}
.tree-unit-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.25rem;
border-radius: var(--pico-border-radius);
}
.tree-unit-row.selected {
background: var(--pico-primary-background);
}
.tree-unit-label {
flex: 1;
text-align: left;
}
.plain {
background: none;
border: none;
cursor: pointer;
padding: 0.15rem 0.25rem;
margin: 0;
color: inherit;
font: inherit;
}
.icon-btn {
font-size: 0.9rem;
opacity: 0.6;
}
.icon-btn:hover { opacity: 1; }
.base-badge {
color: var(--pico-primary);
font-size: 0.8rem;
}
.muted {
color: var(--pico-muted-color);
font-size: 0.85rem;
}
.add-unit-btn {
font-size: 0.85rem;
color: var(--pico-primary);
padding: 0.2rem 0.25rem;
margin-top: 0.25rem;
}
.tree-footer {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--pico-card-border-color);
}
.admin-form-panel {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
padding: 1.25rem;
}
.select-hint {
color: var(--pico-muted-color);
font-style: italic;
margin: 2rem 0;
text-align: center;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.form-error {
color: var(--pico-del-color, #e74c3c);
background: rgba(231, 76, 60, 0.1);
padding: 0.5rem 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.9rem;
}
.form-success {
color: var(--pico-ins-color, #2ecc71);
background: rgba(46, 204, 113, 0.1);
padding: 0.5rem 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.9rem;
}
.tobase-hint {
display: block;
color: var(--pico-muted-color);
margin-top: 0.25rem;
font-style: italic;
}
.logout-btn {
width: auto;
padding: 0.3rem 0.75rem;
margin-bottom: 0;
font-size: 0.9rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-card {
width: 100%;
max-width: 480px;
margin: 0 1rem;
}
</style>