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

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

View File

@@ -15,6 +15,12 @@ export interface AppConfig {
yoloLabel?: string;
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */
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. */

View File

@@ -44,6 +44,8 @@ export interface Group {
sortOrder?: 'defined' | 'alpha';
/** If true, this group (and all its units) is excluded from automatic rendering. */
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. */

View File

@@ -11,6 +11,7 @@ export const load: PageServerLoad = () => {
units: data.units.filter((u) => !u.hidden && !hiddenGroupIds.has(u.group ?? '')),
groups: data.groups.filter((g) => !g.hidden),
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.
*/
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 groups: Group[] = $derived(data.groups);
let yoloLabel: string = $derived(data.yoloLabel);
let yoloDescription: string = $derived(data.yoloDescription);
let yoloVisibility: 'auto' | 'never' = $derived(data.yoloVisibility);
let showYoloToggle: boolean = $derived(
yoloVisibility !== 'never' && groups.length > 1
);
// ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1);
@@ -134,7 +138,7 @@
}
// ── 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
const byGroup = new Map<string | null, ResultItem[]>();
for (const r of results) {
@@ -144,18 +148,18 @@
bucket.push(r);
}
// 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) {
const items = byGroup.get(group.id);
if (items?.length) {
const sorted = group.sortOrder === 'alpha'
? [...items].sort((a, b) => a.unit.label.localeCompare(b.unit.label))
: items;
sections.push({ groupLabel: group.label, items: sorted });
sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: sorted });
}
}
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;
});
</script>
@@ -182,7 +186,8 @@
{/if}
</div>
<div class="yolo-toggle-row">
<div class="yolo-toggle-row" class:yolo-hidden={!showYoloToggle}>
{#if showYoloToggle}
<label class="yolo-label">
<input type="checkbox" bind:checked={yoloMode} role="switch" />
<span class="yolo-label-text">{yoloLabel}</span>
@@ -190,6 +195,7 @@
<small class="yolo-desc">{yoloDescription}</small>
{/if}
</label>
{/if}
</div>
<ConverterCard
@@ -210,6 +216,10 @@
<style>
.yolo-toggle-row {
margin-bottom: 1rem;
min-height: 0;
}
.yolo-toggle-row.yolo-hidden {
margin-bottom: 0;
}
.yolo-label {

View File

@@ -7,7 +7,7 @@
// ── State ────────────────────────────────────────────────────────────────────
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 bulkAction = $state<string>(''); // '' | 'move' | 'delete'
let bulkMoveTargetGroupId = $state<string>('');
@@ -32,6 +32,50 @@
let groupFormSnapshot = $state<Partial<Group>>({});
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 ─────────────────────────────────────────────────────────────
let unitIdManuallyEdited = $state(false);
let groupIdManuallyEdited = $state(false);
@@ -195,7 +239,7 @@
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
}
$effect(() => { loadData(); });
$effect(() => { loadData(); loadConfig(); });
async function extractError(res: Response, fallback: string): Promise<string> {
const body = await res.json().catch(() => ({}));
@@ -842,6 +886,7 @@
<div class="tree-footer">
<button onclick={() => startNewUnit()}>+ Add Unit</button>
<button class="outline" onclick={startNewGroup}>+ Add Group</button>
<button class="outline secondary" onclick={() => { selectedItem = { type: 'settings', id: 'settings' }; }}> Settings</button>
</div>
</aside>
@@ -953,6 +998,10 @@
<option value="alpha">Alphabetical</option>
</select>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={groupForm.alwaysShowLabel} />
<span>Always show group label</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={groupForm.hidden} />
<span>Don't show on front end</span>
@@ -971,6 +1020,35 @@
</div>
</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}
<p class="select-hint">Select a unit or group to edit, or add a new one.</p>
{/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 });
};