feat: sort-alpha button per group, settings in header, sortPosition pin

- ⇅ button on each group header: sorts units alpha (labelPlural + symbol tiebreak) via order API
- ⚙ Settings moved to admin-header alongside Log out
- Unit type gets sortPosition?: number (1-based, optional)
- Converter sort: partitions pinned/floating, inserts pins at requested slots
  Pins > length append at end; gaps fill naturally with floating items
- Admin unit form: Force position number input
This commit is contained in:
Falkan
2026-03-18 22:16:44 -04:00
parent 8ec935d238
commit 61d1d49f4b
4 changed files with 80 additions and 21 deletions

View File

@@ -7,7 +7,7 @@
"symbol": "dl", "symbol": "dl",
"group": "dickloads", "group": "dickloads",
"toBase": 1, "toBase": 1,
"description": "See {pound:description}. Let's also look at {us-oil-bbl}." "description": "The basic unit of measurement for all weights and masses worldwide."
}, },
{ {
"id": "pound", "id": "pound",
@@ -16,25 +16,26 @@
"symbol": "lb", "symbol": "lb",
"group": "dickloads", "group": "dickloads",
"toBase": 0.038, "toBase": 0.038,
"description": "My description is {dickload:description}" "description": "Avoirdupois pounds, as used in the USA."
}, },
{ {
"id": "us-oil-bbl", "id": "us-oil-bbl",
"label": "oil barrel (US)", "label": "barrel",
"labelPlural": "oil barrels (US)", "labelPlural": "barrels",
"symbol": "us oil bbl", "symbol": "bbl us oil",
"group": "dickloads", "group": "dickloads",
"toBase": 13.3192584, "toBase": 13.3192584,
"description": "Weight is based on pure water at 4 degrees celsius.", "description": "Weight is based on pure water at 4 degrees celsius.",
"hidden": false "hidden": false
}, },
{ {
"id": "hogshead", "id": "imperial-hogshead",
"label": "hogshead", "label": "hogshead",
"labelPlural": "hogsheads", "labelPlural": "hogsheads",
"symbol": "hhd", "symbol": "hhd imperial",
"group": "dickloads", "group": "dickloads",
"toBase": 38 "toBase": 27.420633296,
"description": "The hogshead as understood by residents of Old Blighty."
}, },
{ {
"id": "short-ton", "id": "short-ton",
@@ -108,26 +109,34 @@
"id": "us-fluid-bbl", "id": "us-fluid-bbl",
"label": "barrel", "label": "barrel",
"labelPlural": "barrels", "labelPlural": "barrels",
"symbol": "us fluid bbl", "symbol": "bbl us fluid",
"group": "dickloads", "group": "dickloads",
"toBase": 9.9894438 "toBase": 9.9894438
}, },
{ {
"id": "us-dry-bbl", "id": "us-dry-bbl",
"label": "dry barrel", "label": "barrel",
"labelPlural": "dry barrels", "labelPlural": "barrels",
"symbol": "us dry bbl", "symbol": "bbl us dry",
"group": "dickloads", "group": "dickloads",
"toBase": 1 "toBase": 9.6864954123
}, },
{ {
"id": "us-hogshead", "id": "us-hogshead",
"label": "hogshead (US)", "label": "hogshead",
"labelPlural": "hogsheads (US)", "labelPlural": "hogsheads",
"symbol": "us hhd", "symbol": "hhd us",
"group": "dickloads", "group": "dickloads",
"toBase": 19.9788876, "toBase": 19.9788876,
"description": "Equal to two US fluid barrels." "description": "Equal to two US fluid barrels."
},
{
"id": "imperial-barrel",
"label": "barrel",
"labelPlural": "barrels",
"symbol": "bbl imperial",
"group": "dickloads",
"toBase": 13.710316648
} }
], ],
"groups": [ "groups": [

View File

@@ -30,6 +30,14 @@ export interface Unit {
/** If true, this unit is excluded from automatic rendering in the converter. */ /** If true, this unit is excluded from automatic rendering in the converter. */
hidden?: boolean; hidden?: boolean;
/**
* Optional fixed display position (1-based) within the group's rendered list.
* Pinned units are inserted at their requested slot after floating units are ordered.
* If position > total units, the unit is appended at the end in ascending sortPosition order.
* Ties are broken by the group's natural sort order.
*/
sortPosition?: number;
} }
/** A group of units sharing a base unit and a universal scale factor. */ /** A group of units sharing a base unit and a universal scale factor. */

View File

@@ -152,13 +152,29 @@
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' // Base order: alpha or defined
const base = group.sortOrder === 'alpha'
? [...items].sort((a, b) => { ? [...items].sort((a, b) => {
const labelCmp = a.unit.labelPlural.localeCompare(b.unit.labelPlural); const labelCmp = a.unit.labelPlural.localeCompare(b.unit.labelPlural);
return labelCmp !== 0 ? labelCmp : a.unit.symbol.localeCompare(b.unit.symbol); return labelCmp !== 0 ? labelCmp : a.unit.symbol.localeCompare(b.unit.symbol);
}) })
: items; : [...items];
sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: sorted });
// Partition into pinned (sortPosition set) and floating
const pinned = base.filter((r) => r.unit.sortPosition != null)
.sort((a, b) => (a.unit.sortPosition ?? 0) - (b.unit.sortPosition ?? 0));
const floating = base.filter((r) => r.unit.sortPosition == null);
// Insert pinned units at their requested 1-based positions
// Position > length → append at end in sortPosition order
const result: typeof base = [...floating];
for (const p of pinned) {
const pos = Math.max(1, p.unit.sortPosition ?? 1);
const insertAt = Math.min(pos - 1, result.length);
result.splice(insertAt, 0, p);
}
sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: result });
} }
} }
const orphaned = byGroup.get(null); const orphaned = byGroup.get(null);

View File

@@ -638,6 +638,20 @@
formError = null; formError = null;
} }
async function sortGroupAlpha(group: Group) {
const units = unitsData.units.filter((u) => u.group === group.id);
const sorted = [...units].sort((a, b) => {
const cmp = a.labelPlural.localeCompare(b.labelPlural);
return cmp !== 0 ? cmp : a.symbol.localeCompare(b.symbol);
});
const res = await fetch(`${api}/order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ units: { [group.id]: sorted.map((u) => u.id) } })
});
if (res.ok) await loadData();
}
function closeDeleteGroup() { deleteGroupTarget = null; } function closeDeleteGroup() { deleteGroupTarget = null; }
async function confirmDeleteGroup() { async function confirmDeleteGroup() {
@@ -683,7 +697,10 @@
<div class="admin-header"> <div class="admin-header">
<h2>Admin</h2> <h2>Admin</h2>
<button class="outline secondary logout-btn" onclick={logout}>Log out</button> <div class="admin-header-actions">
<button class="outline secondary" onclick={() => { selectedItem = { type: 'settings', id: 'settings' }; }}> Settings</button>
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
</div>
</div> </div>
<div class="admin-panels"> <div class="admin-panels">
@@ -800,6 +817,7 @@
</button> </button>
<!-- Delete only — edit is via click on name --> <!-- Delete only — edit is via click on name -->
<button class="plain icon-btn" title="Sort alphabetically" onclick={() => sortGroupAlpha(group)}>⇅</button>
<button class="plain icon-btn" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button> <button class="plain icon-btn" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button>
</div> </div>
@@ -900,7 +918,6 @@
<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>
@@ -960,6 +977,10 @@
Description <small class="muted">(optional — shown as tooltip in converter)</small> Description <small class="muted">(optional — shown as tooltip in converter)</small>
<textarea bind:value={unitForm.description} placeholder="e.g. A unit based on…" rows="2"></textarea> <textarea bind:value={unitForm.description} placeholder="e.g. A unit based on…" rows="2"></textarea>
</label> </label>
<label>
Force position <small class="muted">(optional — pin this unit to a specific slot in the group)</small>
<input type="number" bind:value={unitForm.sortPosition} min="1" step="1" placeholder="e.g. 3" />
</label>
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" bind:checked={unitForm.hidden} /> <input type="checkbox" bind:checked={unitForm.hidden} />
<span>Don't show on front end</span> <span>Don't show on front end</span>
@@ -1134,6 +1155,11 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.admin-header h2 { margin: 0; } .admin-header h2 { margin: 0; }
.admin-header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.admin-panels { .admin-panels {
display: grid; display: grid;