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:
25
src/routes/admin/+layout.server.ts
Normal file
25
src/routes/admin/+layout.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ request, url }) => {
|
||||
// Skip auth check for the login page itself
|
||||
if (url.pathname.endsWith('/login') || url.pathname.endsWith('/login/')) {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
// Skip auth check for API routes (they handle their own auth)
|
||||
if (url.pathname.includes('/api/')) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const authed = requireAuth(request, config);
|
||||
|
||||
if (!authed) {
|
||||
throw redirect(302, '/admin/login');
|
||||
}
|
||||
|
||||
return { authenticated: true };
|
||||
};
|
||||
13
src/routes/admin/+layout.svelte
Normal file
13
src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="admin-layout">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-layout {
|
||||
min-height: calc(100vh - 8rem);
|
||||
}
|
||||
</style>
|
||||
747
src/routes/admin/+page.svelte
Normal file
747
src/routes/admin/+page.svelte
Normal 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>
|
||||
51
src/routes/admin/api/auth/login/+server.ts
Normal file
51
src/routes/admin/api/auth/login/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadConfig } from '$lib/server/data';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth';
|
||||
import { serialize } from 'cookie';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const COOKIE_MAX_AGE = 86400 * 7; // 7 days
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { password } = body as { password?: string };
|
||||
|
||||
if (!password) {
|
||||
return json({ error: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.passwordHash) {
|
||||
console.warn('[humor-units] WARNING: No password hash set. Run: node scripts/set-password.js <password>');
|
||||
return json({ error: 'Admin not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Timing attack mitigation: ensure response takes at least 500ms on failure
|
||||
const start = Date.now();
|
||||
const valid = await verifyPassword(password, config.passwordHash);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
if (!valid) {
|
||||
const delay = Math.max(0, 500 - elapsed);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return json({ error: 'Incorrect password' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = createSession(config.sessionSecret);
|
||||
const sessionCookie = serialize('hu_session', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: url.protocol === 'https:',
|
||||
path: '/',
|
||||
maxAge: COOKIE_MAX_AGE
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': sessionCookie
|
||||
}
|
||||
});
|
||||
};
|
||||
20
src/routes/admin/api/auth/logout/+server.ts
Normal file
20
src/routes/admin/api/auth/logout/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serialize } from 'cookie';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = ({ url }) => {
|
||||
const clearCookie = serialize('hu_session', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: url.protocol === 'https:',
|
||||
path: '/',
|
||||
maxAge: 0
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': clearCookie
|
||||
}
|
||||
});
|
||||
};
|
||||
64
src/routes/admin/api/groups/+server.ts
Normal file
64
src/routes/admin/api/groups/+server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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());
|
||||
}
|
||||
|
||||
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export const GET: RequestHandler = ({ request }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const data = loadData();
|
||||
return json(data.groups);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
|
||||
const { id, label, baseUnitId, toUniversal } = body as Partial<Group>;
|
||||
|
||||
if (!id || !KEBAB_RE.test(id)) {
|
||||
return json({ error: 'id must be non-empty kebab-case' }, { status: 400 });
|
||||
}
|
||||
if (!label) {
|
||||
return json({ error: 'label is required' }, { status: 400 });
|
||||
}
|
||||
if (typeof toUniversal !== 'number' || toUniversal <= 0) {
|
||||
return json({ error: 'toUniversal must be a positive number' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = loadData();
|
||||
|
||||
if (data.groups.some((g) => g.id === id)) {
|
||||
return json({ error: `Group id '${id}' already exists` }, { status: 400 });
|
||||
}
|
||||
|
||||
// baseUnitId must exist and belong to this group (or be empty for new group)
|
||||
if (baseUnitId) {
|
||||
const baseUnit = data.units.find((u) => u.id === baseUnitId);
|
||||
if (!baseUnit) {
|
||||
return json({ error: `baseUnitId '${baseUnitId}' does not exist` }, { status: 400 });
|
||||
}
|
||||
if (baseUnit.group !== id && baseUnit.group !== null) {
|
||||
return json({ error: `baseUnitId '${baseUnitId}' does not belong to this group` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const newGroup: Group = {
|
||||
id,
|
||||
label,
|
||||
baseUnitId: baseUnitId ?? '',
|
||||
toUniversal
|
||||
};
|
||||
data.groups.push(newGroup);
|
||||
saveData(data);
|
||||
|
||||
return json(newGroup, { status: 201 });
|
||||
};
|
||||
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 });
|
||||
};
|
||||
59
src/routes/admin/api/units/+server.ts
Normal file
59
src/routes/admin/api/units/+server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { Unit } from '$lib/types';
|
||||
|
||||
function auth(request: Request) {
|
||||
return requireAuth(request, loadConfig());
|
||||
}
|
||||
|
||||
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export const GET: RequestHandler = ({ request }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const data = loadData();
|
||||
return json(data.units);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
|
||||
const { id, label, labelPlural, symbol, group, toBase } = body as Partial<Unit>;
|
||||
|
||||
// Validation
|
||||
if (!id || !KEBAB_RE.test(id)) {
|
||||
return json({ error: 'id must be non-empty kebab-case' }, { status: 400 });
|
||||
}
|
||||
if (!label || !labelPlural || !symbol) {
|
||||
return json({ error: 'label, labelPlural, and symbol are required' }, { status: 400 });
|
||||
}
|
||||
if (typeof toBase !== 'number' || toBase <= 0) {
|
||||
return json({ error: 'toBase must be a positive number' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = loadData();
|
||||
|
||||
if (data.units.some((u) => u.id === id)) {
|
||||
return json({ error: `Unit id '${id}' already exists` }, { status: 400 });
|
||||
}
|
||||
if (group !== null && group !== undefined && !data.groups.some((g) => g.id === group)) {
|
||||
return json({ error: `Group '${group}' does not exist` }, { status: 400 });
|
||||
}
|
||||
|
||||
const newUnit: Unit = {
|
||||
id,
|
||||
label,
|
||||
labelPlural,
|
||||
symbol,
|
||||
group: group ?? null,
|
||||
toBase
|
||||
};
|
||||
data.units.push(newUnit);
|
||||
saveData(data);
|
||||
|
||||
return json(newUnit, { status: 201 });
|
||||
};
|
||||
61
src/routes/admin/api/units/[id]/+server.ts
Normal file
61
src/routes/admin/api/units/[id]/+server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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 { Unit } from '$lib/types';
|
||||
|
||||
function auth(request: Request) {
|
||||
return requireAuth(request, loadConfig());
|
||||
}
|
||||
|
||||
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
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 idx = data.units.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return json({ error: 'Unit not found' }, { status: 404 });
|
||||
|
||||
const patch = body as Partial<Unit>;
|
||||
|
||||
// If id is changing, validate new id
|
||||
if (patch.id !== undefined && patch.id !== id) {
|
||||
if (!KEBAB_RE.test(patch.id)) {
|
||||
return json({ error: 'id must be kebab-case' }, { status: 400 });
|
||||
}
|
||||
if (data.units.some((u) => u.id === patch.id)) {
|
||||
return json({ error: `Unit id '${patch.id}' already exists` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (patch.toBase !== undefined && patch.toBase <= 0) {
|
||||
return json({ error: 'toBase must be positive' }, { status: 400 });
|
||||
}
|
||||
if (patch.group !== null && patch.group !== undefined && !data.groups.some((g) => g.id === patch.group)) {
|
||||
return json({ error: `Group '${patch.group}' does not exist` }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated: Unit = { ...data.units[idx], ...patch };
|
||||
data.units[idx] = updated;
|
||||
saveData(data);
|
||||
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = ({ request, params }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = params;
|
||||
const data = loadData();
|
||||
const idx = data.units.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return json({ error: 'Unit not found' }, { status: 404 });
|
||||
|
||||
data.units.splice(idx, 1);
|
||||
saveData(data);
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
85
src/routes/admin/login/+page.svelte
Normal file
85
src/routes/admin/login/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await goto('/admin');
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.error ?? 'Incorrect password';
|
||||
}
|
||||
} catch {
|
||||
error = 'Network error — please try again';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Humor Units — Admin Login</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<article class="login-card">
|
||||
<h2>Admin Login</h2>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{#if error}
|
||||
<p class="login-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: var(--pico-del-color, #e74c3c);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user