feat: yoloVisibility config, alwaysShowLabel on groups, site settings panel

- AppConfig gains yoloVisibility: 'auto' | 'never' (default: auto)
- Group gains alwaysShowLabel?: boolean
- YOLO toggle only renders when showYoloToggle is true (auto + >1 group)
- Group divider shows when >1 group OR alwaysShowLabel is set
- New /admin/api/config GET+PUT endpoint for yolo settings
- Admin: alwaysShowLabel checkbox in group form
- Admin: ⚙ Settings panel with YOLO label, description, and visibility controls
This commit is contained in:
Falkan
2026-03-18 20:15:45 -04:00
parent 2c27edc487
commit b020b74c31
8 changed files with 158 additions and 21 deletions

View File

@@ -1,14 +1,5 @@
{ {
"units": [ "units": [
{
"id": "pound",
"label": "pound",
"labelPlural": "pounds",
"symbol": "lb",
"group": "dickloads",
"toBase": 0.038,
"description": "My description is {dickload:description}"
},
{ {
"id": "dickload", "id": "dickload",
"label": "dickload", "label": "dickload",
@@ -18,6 +9,15 @@
"toBase": 1, "toBase": 1,
"description": "See {pound:description}" "description": "See {pound:description}"
}, },
{
"id": "pound",
"label": "pound",
"labelPlural": "pounds",
"symbol": "lb",
"group": "dickloads",
"toBase": 0.038,
"description": "My description is {dickload:description}"
},
{ {
"id": "barrel", "id": "barrel",
"label": "barrel", "label": "barrel",
@@ -92,12 +92,30 @@
"symbol": "shttn", "symbol": "shttn",
"group": "dickloads", "group": "dickloads",
"toBase": 38 "toBase": 38
},
{
"id": "asdf",
"label": "asdf",
"labelPlural": "asdf",
"symbol": "asdf",
"group": "another-group",
"toBase": 1,
"hidden": true
} }
], ],
"groups": [ "groups": [
{ {
"id": "dickloads", "id": "dickloads",
"label": "Dickloads", "label": "Weights and Masses",
"baseUnitId": "",
"toUniversal": 1,
"sortOrder": "alpha",
"hidden": false,
"alwaysShowLabel": true
},
{
"id": "another-group",
"label": "Another Group",
"baseUnitId": "", "baseUnitId": "",
"toUniversal": 1 "toUniversal": 1
} }

View File

@@ -27,7 +27,7 @@
fromUnitId: string; fromUnitId: string;
highlightedUnitId: string | null; highlightedUnitId: string | null;
results: ResultItem[]; results: ResultItem[];
orderedResults: { groupLabel: string | null; items: ResultItem[] }[]; orderedResults: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[];
oninputchange: (v: number) => void; oninputchange: (v: number) => void;
onunitchange: (id: string) => void; onunitchange: (id: string) => void;
onhighlight: (id: string) => void; onhighlight: (id: string) => void;
@@ -46,7 +46,7 @@
</div> </div>
{#each orderedResults as section} {#each orderedResults as section}
{#if orderedResults.length > 1 && section.groupLabel} {#if (orderedResults.length > 1 || section.alwaysShowLabel) && section.groupLabel}
<div class="group-divider">{section.groupLabel}</div> <div class="group-divider">{section.groupLabel}</div>
{/if} {/if}
<div class="results-grid" use:uniformFontSize={{ results: section.items }}> <div class="results-grid" use:uniformFontSize={{ results: section.items }}>

View File

@@ -15,6 +15,12 @@ export interface AppConfig {
yoloLabel?: string; yoloLabel?: string;
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */ /** Description shown inline after the label. Defaults to "(cross-group conversions)". */
yoloDescription?: string; yoloDescription?: string;
/**
* Controls when the YOLO mode toggle is visible to users.
* 'auto' — show only when more than one group is present (default)
* 'never' — always hidden
*/
yoloVisibility?: 'auto' | 'never';
} }
/** Read units.json synchronously. Returns parsed UnitsData. */ /** Read units.json synchronously. Returns parsed UnitsData. */

View File

@@ -44,6 +44,8 @@ export interface Group {
sortOrder?: 'defined' | 'alpha'; sortOrder?: 'defined' | 'alpha';
/** If true, this group (and all its units) is excluded from automatic rendering. */ /** If true, this group (and all its units) is excluded from automatic rendering. */
hidden?: boolean; hidden?: boolean;
/** If true, the group label is shown in the converter even when only one group is present. */
alwaysShowLabel?: boolean;
} }
/** Full data payload for units and groups. */ /** Full data payload for units and groups. */

View File

@@ -11,6 +11,7 @@ export const load: PageServerLoad = () => {
units: data.units.filter((u) => !u.hidden && !hiddenGroupIds.has(u.group ?? '')), units: data.units.filter((u) => !u.hidden && !hiddenGroupIds.has(u.group ?? '')),
groups: data.groups.filter((g) => !g.hidden), groups: data.groups.filter((g) => !g.hidden),
yoloLabel: config.yoloLabel ?? 'YOLO mode', yoloLabel: config.yoloLabel ?? 'YOLO mode',
yoloDescription: config.yoloDescription ?? '(cross-group conversions)' yoloDescription: config.yoloDescription ?? '(cross-group conversions)',
yoloVisibility: config.yoloVisibility ?? 'auto',
}; };
}; };

View File

@@ -14,12 +14,16 @@
* Supports same-group and cross-group (YOLO) conversions. * Supports same-group and cross-group (YOLO) conversions.
*/ */
let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string } } = $props(); let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string; yoloVisibility: 'auto' | 'never' } } = $props();
let units: Unit[] = $derived(data.units); let units: Unit[] = $derived(data.units);
let groups: Group[] = $derived(data.groups); let groups: Group[] = $derived(data.groups);
let yoloLabel: string = $derived(data.yoloLabel); let yoloLabel: string = $derived(data.yoloLabel);
let yoloDescription: string = $derived(data.yoloDescription); let yoloDescription: string = $derived(data.yoloDescription);
let yoloVisibility: 'auto' | 'never' = $derived(data.yoloVisibility);
let showYoloToggle: boolean = $derived(
yoloVisibility !== 'never' && groups.length > 1
);
// ── Reactive state ────────────────────────────────────────────────────────── // ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1); let inputValue = $state<number>(1);
@@ -134,7 +138,7 @@
} }
// ── Derived: results per group for display — single O(n) pass ────────────── // ── Derived: results per group for display — single O(n) pass ──────────────
let orderedResults: { groupLabel: string | null; items: ResultItem[] }[] = $derived.by(() => { let orderedResults: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = $derived.by(() => {
// Build a Map of groupId → items in a single pass over results // Build a Map of groupId → items in a single pass over results
const byGroup = new Map<string | null, ResultItem[]>(); const byGroup = new Map<string | null, ResultItem[]>();
for (const r of results) { for (const r of results) {
@@ -144,18 +148,18 @@
bucket.push(r); bucket.push(r);
} }
// Emit sections in group-definition order, then orphaned at end // Emit sections in group-definition order, then orphaned at end
const sections: { groupLabel: string | null; items: ResultItem[] }[] = []; const sections: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = [];
for (const group of groups) { for (const group of groups) {
const items = byGroup.get(group.id); const items = byGroup.get(group.id);
if (items?.length) { if (items?.length) {
const sorted = group.sortOrder === 'alpha' const sorted = group.sortOrder === 'alpha'
? [...items].sort((a, b) => a.unit.label.localeCompare(b.unit.label)) ? [...items].sort((a, b) => a.unit.label.localeCompare(b.unit.label))
: items; : items;
sections.push({ groupLabel: group.label, items: sorted }); sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: sorted });
} }
} }
const orphaned = byGroup.get(null); const orphaned = byGroup.get(null);
if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', items: orphaned }); if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', alwaysShowLabel: false, items: orphaned });
return sections; return sections;
}); });
</script> </script>
@@ -182,7 +186,8 @@
{/if} {/if}
</div> </div>
<div class="yolo-toggle-row"> <div class="yolo-toggle-row" class:yolo-hidden={!showYoloToggle}>
{#if showYoloToggle}
<label class="yolo-label"> <label class="yolo-label">
<input type="checkbox" bind:checked={yoloMode} role="switch" /> <input type="checkbox" bind:checked={yoloMode} role="switch" />
<span class="yolo-label-text">{yoloLabel}</span> <span class="yolo-label-text">{yoloLabel}</span>
@@ -190,6 +195,7 @@
<small class="yolo-desc">{yoloDescription}</small> <small class="yolo-desc">{yoloDescription}</small>
{/if} {/if}
</label> </label>
{/if}
</div> </div>
<ConverterCard <ConverterCard
@@ -210,6 +216,10 @@
<style> <style>
.yolo-toggle-row { .yolo-toggle-row {
margin-bottom: 1rem; margin-bottom: 1rem;
min-height: 0;
}
.yolo-toggle-row.yolo-hidden {
margin-bottom: 0;
} }
.yolo-label { .yolo-label {

View File

@@ -7,7 +7,7 @@
// ── State ──────────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────────
let unitsData = $state<UnitsData>({ units: [], groups: [] }); let unitsData = $state<UnitsData>({ units: [], groups: [] });
let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null); let selectedItem = $state<{ type: 'unit' | 'group' | 'settings'; id: string } | null>(null);
let checkedUnitIds = $state<Set<string>>(new Set()); let checkedUnitIds = $state<Set<string>>(new Set());
let bulkAction = $state<string>(''); // '' | 'move' | 'delete' let bulkAction = $state<string>(''); // '' | 'move' | 'delete'
let bulkMoveTargetGroupId = $state<string>(''); let bulkMoveTargetGroupId = $state<string>('');
@@ -32,6 +32,50 @@
let groupFormSnapshot = $state<Partial<Group>>({}); let groupFormSnapshot = $state<Partial<Group>>({});
let groupFieldError = $state<string | null>(null); // which field has error let groupFieldError = $state<string | null>(null); // which field has error
// Site config state
let configYoloLabel = $state('YOLO mode');
let configYoloDescription = $state('(cross-group conversions)');
let configYoloVisibility = $state<'auto' | 'never'>('auto');
let configSaving = $state(false);
let configError = $state<string | null>(null);
let configSuccess = $state<string | null>(null);
async function loadConfig() {
const res = await fetch(`${api}/config`);
if (!res.ok) return;
const c = await res.json();
configYoloLabel = c.yoloLabel ?? 'YOLO mode';
configYoloDescription = c.yoloDescription ?? '(cross-group conversions)';
configYoloVisibility = c.yoloVisibility ?? 'auto';
}
async function saveConfig() {
configSaving = true;
configError = null;
configSuccess = null;
try {
const res = await fetch(`${api}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
yoloLabel: configYoloLabel,
yoloDescription: configYoloDescription,
yoloVisibility: configYoloVisibility,
})
});
if (!res.ok) { configError = 'Save failed'; }
else { configSuccess = 'Saved.'; }
} catch { configError = 'Network error'; }
configSaving = false;
}
function onConfigKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
e.preventDefault();
saveConfig();
}
}
// ── Auto-ID state ───────────────────────────────────────────────────────────── // ── Auto-ID state ─────────────────────────────────────────────────────────────
let unitIdManuallyEdited = $state(false); let unitIdManuallyEdited = $state(false);
let groupIdManuallyEdited = $state(false); let groupIdManuallyEdited = $state(false);
@@ -195,7 +239,7 @@
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]); expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
} }
$effect(() => { loadData(); }); $effect(() => { loadData(); loadConfig(); });
async function extractError(res: Response, fallback: string): Promise<string> { async function extractError(res: Response, fallback: string): Promise<string> {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
@@ -842,6 +886,7 @@
<div class="tree-footer"> <div class="tree-footer">
<button onclick={() => startNewUnit()}>+ Add Unit</button> <button onclick={() => startNewUnit()}>+ Add Unit</button>
<button class="outline" onclick={startNewGroup}>+ Add Group</button> <button class="outline" onclick={startNewGroup}>+ Add Group</button>
<button class="outline secondary" onclick={() => { selectedItem = { type: 'settings', id: 'settings' }; }}> Settings</button>
</div> </div>
</aside> </aside>
@@ -953,6 +998,10 @@
<option value="alpha">Alphabetical</option> <option value="alpha">Alphabetical</option>
</select> </select>
</label> </label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={groupForm.alwaysShowLabel} />
<span>Always show group label</span>
</label>
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" bind:checked={groupForm.hidden} /> <input type="checkbox" bind:checked={groupForm.hidden} />
<span>Don't show on front end</span> <span>Don't show on front end</span>
@@ -971,6 +1020,35 @@
</div> </div>
</div><!-- /onkeydown group form --> </div><!-- /onkeydown group form -->
{:else if selectedItem?.type === 'settings'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onkeydown={onConfigKeydown}>
<h3>Site Settings</h3>
{#if configError}<p class="form-msg form-error" role="alert">{configError}</p>{/if}
{#if configSuccess}<p class="form-msg form-success" role="status">{configSuccess}</p>{/if}
<fieldset>
<legend>YOLO Mode Toggle</legend>
<label>
Label
<input type="text" bind:value={configYoloLabel} placeholder="YOLO mode" />
</label>
<label>
Description <small class="muted">(shown inline after label)</small>
<input type="text" bind:value={configYoloDescription} placeholder="(cross-group conversions)" />
</label>
<label>
Visibility
<select bind:value={configYoloVisibility}>
<option value="auto">Auto — show when more than one group is present</option>
<option value="never">Never — always hidden</option>
</select>
</label>
</fieldset>
<div class="form-actions">
<button onclick={saveConfig} disabled={configSaving}>{configSaving ? 'Saving…' : 'Save'}</button>
</div>
</div>
{:else} {:else}
<p class="select-hint">Select a unit or group to edit, or add a new one.</p> <p class="select-hint">Select a unit or group to edit, or add a new one.</p>
{/if} {/if}

View File

@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import { loadConfig, saveConfig } from '$lib/server/data';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = () => {
const { yoloLabel, yoloDescription, yoloVisibility } = loadConfig();
return json({ yoloLabel, yoloDescription, yoloVisibility });
};
export const PUT: RequestHandler = async ({ request }) => {
const body = await request.json();
const config = loadConfig();
if (typeof body.yoloLabel === 'string') config.yoloLabel = body.yoloLabel;
if (typeof body.yoloDescription === 'string') config.yoloDescription = body.yoloDescription;
if (body.yoloVisibility === 'auto' || body.yoloVisibility === 'never') {
config.yoloVisibility = body.yoloVisibility;
}
saveConfig(config);
return json({ ok: true });
};