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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
22
src/routes/admin/api/config/+server.ts
Normal file
22
src/routes/admin/api/config/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user