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:
@@ -2,26 +2,47 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { florpUnits } from '$lib/units/definitions';
|
||||
import { convertById } from '$lib/convert';
|
||||
import { convert, convertYolo, canConvert } from '$lib/convert';
|
||||
import { FRACTIONAL_DIGITS } from '$lib/config';
|
||||
import { formatBig } from '$lib/format';
|
||||
import Big from 'big.js';
|
||||
import type { Unit, ResultItem } from '$lib/types';
|
||||
import type { Unit, Group, ResultItem } from '$lib/types';
|
||||
import ConverterCard from '../components/ConverterCard.svelte';
|
||||
|
||||
/**
|
||||
* Converter page — owns all reactive state.
|
||||
* Phase 2: live conversion, URL state, highlight interaction.
|
||||
* Phase 3 will add NL input.
|
||||
* Converter page — loads units dynamically from server props.
|
||||
* Supports same-group and cross-group (YOLO) conversions.
|
||||
*/
|
||||
|
||||
const units: Unit[] = florpUnits;
|
||||
let { data }: { data: { units: Unit[]; groups: Group[] } } = $props();
|
||||
|
||||
let units: Unit[] = $derived(data.units);
|
||||
let groups: Group[] = $derived(data.groups);
|
||||
|
||||
// ── Reactive state ──────────────────────────────────────────────────────────
|
||||
let inputValue = $state<number>(1);
|
||||
let fromUnitId = $state<string>(units[0].id);
|
||||
let fromUnitId = $state<string>('');
|
||||
let highlightedUnitId = $state<string | null>(null);
|
||||
let yoloMode = $state<boolean>(false);
|
||||
|
||||
// Initialize fromUnitId to first unit once data arrives
|
||||
$effect(() => {
|
||||
if (fromUnitId === '' && units.length > 0) {
|
||||
fromUnitId = units[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Load YOLO preference from localStorage ──────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const stored = localStorage.getItem('yolo-mode');
|
||||
if (stored === 'true') yoloMode = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem('yolo-mode', String(yoloMode));
|
||||
});
|
||||
|
||||
// ── Initialize from URL params on first client-side load ───────────────────
|
||||
if (browser) {
|
||||
@@ -34,35 +55,52 @@
|
||||
const n = parseFloat(pValue);
|
||||
if (!isNaN(n)) inputValue = n;
|
||||
}
|
||||
if (pFrom !== null && units.some((u) => u.id === pFrom)) {
|
||||
if (pFrom !== null && data.units.some((u) => u.id === pFrom)) {
|
||||
fromUnitId = pFrom;
|
||||
}
|
||||
if (pHighlight !== null && units.some((u) => u.id === pHighlight)) {
|
||||
if (pHighlight !== null && data.units.some((u) => u.id === pHighlight)) {
|
||||
highlightedUnitId = pHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived: results for ALL units in definition order ─────────────────────
|
||||
// convert.ts already handles the same-unit identity (from.id === to.id → return value),
|
||||
// so no special-casing needed here for the from-unit.
|
||||
let results = $derived<ResultItem[]>(
|
||||
units.map((u) => ({
|
||||
unit: u,
|
||||
convertedValue: convertById(inputValue, fromUnitId, u.id, units)
|
||||
}))
|
||||
// ── Derived: results for ALL units ─────────────────────────────────────────
|
||||
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
|
||||
|
||||
let results: ResultItem[] = $derived(
|
||||
fromUnit
|
||||
? units.map((u) => {
|
||||
const sameGroup = canConvert(fromUnit, u);
|
||||
if (sameGroup) {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: convert(Big(inputValue), fromUnit, u),
|
||||
isYolo: false
|
||||
};
|
||||
} else if (yoloMode) {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: convertYolo(Big(inputValue), fromUnit, u, groups),
|
||||
isYolo: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: null,
|
||||
isYolo: false
|
||||
};
|
||||
}
|
||||
})
|
||||
: []
|
||||
);
|
||||
|
||||
// ── Derived: highlighted result (unit + value) sourced from full results ────
|
||||
// Results now includes all units, so from-unit can be highlighted too.
|
||||
// ── Derived: highlighted result ─────────────────────────────────────────────
|
||||
let highlightedResult = $derived(
|
||||
highlightedUnitId !== null
|
||||
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
|
||||
: null
|
||||
);
|
||||
|
||||
let fromUnit = $derived(units.find(u => u.id === fromUnitId) ?? units[0]);
|
||||
|
||||
// ── URL sync: update URL only when serialized params actually change ────────
|
||||
// ── URL sync ────────────────────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const params = new URLSearchParams();
|
||||
@@ -80,7 +118,6 @@
|
||||
// ── Event handlers ──────────────────────────────────────────────────────────
|
||||
function handleUnitChange(id: string) {
|
||||
fromUnitId = id;
|
||||
// Clear highlight if the selected unit becomes the new from-unit.
|
||||
if (highlightedUnitId === id) {
|
||||
highlightedUnitId = null;
|
||||
}
|
||||
@@ -91,18 +128,29 @@
|
||||
}
|
||||
|
||||
function handleSetInput(id: string) {
|
||||
// Shift+click: promote result unit to from-unit.
|
||||
// inputValue is NOT changed — outputs recalculate from the same inputValue with the new fromUnitId.
|
||||
// highlightedUnitId is NOT cleared — the two states are independent.
|
||||
fromUnitId = id;
|
||||
}
|
||||
|
||||
function handleCtrlClick(id: string, convertedValue: Big) {
|
||||
// Ctrl+click: adopt this tile's displayed value as the new inputValue and promote it to from-unit.
|
||||
// This is the "pivot" gesture.
|
||||
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
|
||||
fromUnitId = id;
|
||||
}
|
||||
|
||||
// ── Group units for display ─────────────────────────────────────────────────
|
||||
let orderedResults: { groupLabel: string | null; items: ResultItem[] }[] = $derived.by(() => {
|
||||
const sections: { groupLabel: string | null; items: ResultItem[] }[] = [];
|
||||
for (const group of groups) {
|
||||
const items = results.filter((r) => r.unit.group === group.id);
|
||||
if (items.length > 0) {
|
||||
sections.push({ groupLabel: group.label, items });
|
||||
}
|
||||
}
|
||||
const orphaned = results.filter((r) => r.unit.group === null);
|
||||
if (orphaned.length > 0) {
|
||||
sections.push({ groupLabel: 'Ungrouped', items: orphaned });
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -110,27 +158,64 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="highlighted-result" class:has-highlight={highlightedResult !== null} aria-live="polite">
|
||||
{#if highlightedResult !== null}
|
||||
{#if highlightedResult !== null && highlightedResult.convertedValue !== null}
|
||||
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||||
<div class="highlighted-equation">
|
||||
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit.labelPlural}</span>
|
||||
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit?.labelPlural}</span>
|
||||
<span class="highlighted-eq-sep">=</span>
|
||||
<span class="highlighted-eq-to">{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}</span>
|
||||
<span class="highlighted-eq-to">
|
||||
{#if highlightedResult.isYolo}<span class="yolo-marker">~</span>{/if}{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}
|
||||
</span>
|
||||
</div>
|
||||
{:else if highlightedResult !== null && highlightedResult.convertedValue === null}
|
||||
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||||
<span class="highlighted-placeholder">N/A — enable YOLO mode for cross-group conversions</span>
|
||||
{:else}
|
||||
<span class="highlighted-placeholder">Click a result to highlight it</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="yolo-toggle-row">
|
||||
<label class="yolo-label">
|
||||
<input type="checkbox" bind:checked={yoloMode} role="switch" />
|
||||
YOLO mode <small>(cross-group conversions)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ConverterCard
|
||||
{units}
|
||||
{groups}
|
||||
{inputValue}
|
||||
{fromUnitId}
|
||||
{highlightedUnitId}
|
||||
{results}
|
||||
{orderedResults}
|
||||
oninputchange={(v) => (inputValue = v)}
|
||||
onunitchange={handleUnitChange}
|
||||
onhighlight={handleHighlight}
|
||||
onsetinput={handleSetInput}
|
||||
onctrlclick={handleCtrlClick}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.yolo-toggle-row {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.yolo-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.yolo-marker {
|
||||
color: var(--pico-muted-color);
|
||||
font-weight: bold;
|
||||
margin-right: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user