refactor: admin left panel — wider, less cluttered, persistent bulk action bar
- Left panel widened to 340px (was 280px) - Removed redundant edit pencil icons from unit rows — clicking the row selects it - Group names truncate with ellipsis instead of wrapping - Added select-all/none checkbox per group header (with indeterminate state) - Bulk action bar is now persistent at top of left panel, always visible - Bulk actions use two-dropdown pattern: Action (Move to group) → Target group - 'Add unit' text shortened to '+ Add unit', no more wrapping - Delete-group orphan label clarified: 'Delete group only — keep units as Ungrouped'
This commit is contained in:
@@ -5,7 +5,8 @@
|
|||||||
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'; id: string } | null>(null);
|
||||||
let checkedUnitIds = $state<Set<string>>(new Set());
|
let checkedUnitIds = $state<Set<string>>(new Set());
|
||||||
let moveTargetGroupId = $state<string>('');
|
let bulkAction = $state<string>(''); // '' | 'move'
|
||||||
|
let bulkMoveTargetGroupId = $state<string>('');
|
||||||
let expandedGroups = $state<Set<string>>(new Set());
|
let expandedGroups = $state<Set<string>>(new Set());
|
||||||
let deleteGroupTarget = $state<Group | null>(null);
|
let deleteGroupTarget = $state<Group | null>(null);
|
||||||
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
|
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
let groupForm = $state<Partial<Group>>({});
|
let groupForm = $state<Partial<Group>>({});
|
||||||
let groupFormNew = $state(false);
|
let groupFormNew = $state(false);
|
||||||
|
|
||||||
// ── Load data on mount ───────────────────────────────────────────────────────
|
// ── Load data ────────────────────────────────────────────────────────────────
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [res, gres] = await Promise.all([
|
const [res, gres] = await Promise.all([
|
||||||
fetch('/admin/api/units'),
|
fetch('/admin/api/units'),
|
||||||
@@ -32,16 +33,11 @@
|
|||||||
const units: Unit[] = await res.json();
|
const units: Unit[] = await res.json();
|
||||||
const groups: Group[] = gres.ok ? await gres.json() : [];
|
const groups: Group[] = gres.ok ? await gres.json() : [];
|
||||||
unitsData = { units, groups };
|
unitsData = { units, groups };
|
||||||
// Expand all groups — use assignment for consistent reactive update
|
|
||||||
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
|
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load on mount ────────────────────────────────────────────────────────────
|
$effect(() => { loadData(); });
|
||||||
$effect(() => {
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Extract a server error message from a non-ok response. */
|
|
||||||
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(() => ({}));
|
||||||
return (body as Record<string, string>).error ?? fallback;
|
return (body as Record<string, string>).error ?? fallback;
|
||||||
@@ -58,10 +54,6 @@
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getBaseUnit(group: Group): Unit | undefined {
|
|
||||||
return unitsData.units.find((u) => u.id === group.baseUnitId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Selection ────────────────────────────────────────────────────────────────
|
// ── Selection ────────────────────────────────────────────────────────────────
|
||||||
function selectUnit(unit: Unit) {
|
function selectUnit(unit: Unit) {
|
||||||
selectedItem = { type: 'unit', id: unit.id };
|
selectedItem = { type: 'unit', id: unit.id };
|
||||||
@@ -99,29 +91,75 @@
|
|||||||
formSuccess = null;
|
formSuccess = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unit form submit ─────────────────────────────────────────────────────────
|
// ── Checkbox helpers ─────────────────────────────────────────────────────────
|
||||||
|
function toggleCheck(unitId: string) {
|
||||||
|
const next = new Set(checkedUnitIds);
|
||||||
|
if (next.has(unitId)) next.delete(unitId);
|
||||||
|
else next.add(unitId);
|
||||||
|
checkedUnitIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroupCheck(groupId: string | null) {
|
||||||
|
const groupUnits = unitsByGroup.get(groupId) ?? [];
|
||||||
|
const allChecked = groupUnits.every((u) => checkedUnitIds.has(u.id));
|
||||||
|
const next = new Set(checkedUnitIds);
|
||||||
|
for (const u of groupUnits) {
|
||||||
|
if (allChecked) next.delete(u.id);
|
||||||
|
else next.add(u.id);
|
||||||
|
}
|
||||||
|
checkedUnitIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAllChecked(groupId: string | null): boolean {
|
||||||
|
const units = unitsByGroup.get(groupId) ?? [];
|
||||||
|
return units.length > 0 && units.every((u) => checkedUnitIds.has(u.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSomeChecked(groupId: string | null): boolean {
|
||||||
|
const units = unitsByGroup.get(groupId) ?? [];
|
||||||
|
return units.some((u) => checkedUnitIds.has(u.id)) && !groupAllChecked(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk move ────────────────────────────────────────────────────────────────
|
||||||
|
async function executeBulkAction() {
|
||||||
|
if (bulkAction === 'move') {
|
||||||
|
if (!bulkMoveTargetGroupId || checkedUnitIds.size === 0) return;
|
||||||
|
formError = null;
|
||||||
|
const results = await Promise.all(
|
||||||
|
[...checkedUnitIds].map((unitId) =>
|
||||||
|
fetch(`/admin/api/units/${unitId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ group: bulkMoveTargetGroupId })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length) formError = `${failed.length} unit(s) failed to move`;
|
||||||
|
checkedUnitIds = new Set();
|
||||||
|
bulkAction = '';
|
||||||
|
bulkMoveTargetGroupId = '';
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit save/delete ─────────────────────────────────────────────────────────
|
||||||
async function saveUnit() {
|
async function saveUnit() {
|
||||||
formError = null;
|
formError = null;
|
||||||
formSuccess = null;
|
formSuccess = null;
|
||||||
try {
|
try {
|
||||||
let res: Response;
|
const res = unitFormNew
|
||||||
if (unitFormNew) {
|
? await fetch('/admin/api/units', {
|
||||||
res = await fetch('/admin/api/units', {
|
method: 'POST',
|
||||||
method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(unitForm)
|
||||||
body: JSON.stringify(unitForm)
|
})
|
||||||
});
|
: await fetch(`/admin/api/units/${selectedItem?.id}`, {
|
||||||
} else {
|
method: 'PUT',
|
||||||
res = await fetch(`/admin/api/units/${selectedItem?.id}`, {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'PUT',
|
body: JSON.stringify(unitForm)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
});
|
||||||
body: JSON.stringify(unitForm)
|
if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
formError = await extractError(res, 'Save failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
|
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
|
||||||
if (unitFormNew) {
|
if (unitFormNew) {
|
||||||
const created = (await res.json()) as Unit;
|
const created = (await res.json()) as Unit;
|
||||||
@@ -129,9 +167,7 @@
|
|||||||
selectedItem = { type: 'unit', id: created.id ?? '' };
|
selectedItem = { type: 'unit', id: created.id ?? '' };
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch { formError = 'Network error'; }
|
||||||
formError = 'Network error';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUnit() {
|
async function deleteUnit() {
|
||||||
@@ -139,41 +175,30 @@
|
|||||||
if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
|
if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
|
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) {
|
if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
|
||||||
formError = await extractError(res, 'Delete failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedItem = null;
|
selectedItem = null;
|
||||||
unitForm = {};
|
unitForm = {};
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch { formError = 'Network error'; }
|
||||||
formError = 'Network error';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group form submit ────────────────────────────────────────────────────────
|
// ── Group save/delete ─────────────────────────────────────────────────────────
|
||||||
async function saveGroup() {
|
async function saveGroup() {
|
||||||
formError = null;
|
formError = null;
|
||||||
formSuccess = null;
|
formSuccess = null;
|
||||||
try {
|
try {
|
||||||
let res: Response;
|
const res = groupFormNew
|
||||||
if (groupFormNew) {
|
? await fetch('/admin/api/groups', {
|
||||||
res = await fetch('/admin/api/groups', {
|
method: 'POST',
|
||||||
method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(groupForm)
|
||||||
body: JSON.stringify(groupForm)
|
})
|
||||||
});
|
: await fetch(`/admin/api/groups/${selectedItem?.id}`, {
|
||||||
} else {
|
method: 'PUT',
|
||||||
res = await fetch(`/admin/api/groups/${selectedItem?.id}`, {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'PUT',
|
body: JSON.stringify(groupForm)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
});
|
||||||
body: JSON.stringify(groupForm)
|
if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
formError = await extractError(res, 'Save failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
|
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
|
||||||
if (groupFormNew) {
|
if (groupFormNew) {
|
||||||
const created = (await res.json()) as Group;
|
const created = (await res.json()) as Group;
|
||||||
@@ -181,12 +206,9 @@
|
|||||||
selectedItem = { type: 'group', id: created.id ?? '' };
|
selectedItem = { type: 'group', id: created.id ?? '' };
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch { formError = 'Network error'; }
|
||||||
formError = 'Network error';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete group modal ───────────────────────────────────────────────────────
|
|
||||||
function openDeleteGroup(group: Group) {
|
function openDeleteGroup(group: Group) {
|
||||||
deleteGroupTarget = group;
|
deleteGroupTarget = group;
|
||||||
deleteGroupAction = 'reassign';
|
deleteGroupAction = 'reassign';
|
||||||
@@ -195,83 +217,36 @@
|
|||||||
formError = null;
|
formError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDeleteGroup() {
|
function closeDeleteGroup() { deleteGroupTarget = null; }
|
||||||
deleteGroupTarget = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDeleteGroup() {
|
async function confirmDeleteGroup() {
|
||||||
if (!deleteGroupTarget) return;
|
if (!deleteGroupTarget) return;
|
||||||
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
|
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
|
||||||
|
const body: Record<string, unknown> = unitsCount === 0 || deleteGroupAction === 'orphan'
|
||||||
let body: Record<string, unknown>;
|
? { action: 'orphan' }
|
||||||
if (unitsCount === 0) {
|
: { action: 'reassign', targetGroupId: deleteGroupTargetId, toBaseAction: deleteGroupToBaseAction };
|
||||||
body = { action: 'orphan' };
|
|
||||||
} else if (deleteGroupAction === 'reassign') {
|
|
||||||
body = {
|
|
||||||
action: 'reassign',
|
|
||||||
targetGroupId: deleteGroupTargetId,
|
|
||||||
toBaseAction: deleteGroupToBaseAction
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
body = { action: 'orphan' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, {
|
const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
|
||||||
formError = await extractError(res, 'Delete failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteGroupTarget = null;
|
deleteGroupTarget = null;
|
||||||
selectedItem = null;
|
selectedItem = null;
|
||||||
groupForm = {};
|
groupForm = {};
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch { formError = 'Network error'; }
|
||||||
formError = 'Network error';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-select move ────────────────────────────────────────────────────────
|
// ── toBase hint ──────────────────────────────────────────────────────────────
|
||||||
async function moveCheckedUnits() {
|
|
||||||
if (!moveTargetGroupId || checkedUnitIds.size === 0) return;
|
|
||||||
formError = null;
|
|
||||||
const results = await Promise.all(
|
|
||||||
[...checkedUnitIds].map((unitId) =>
|
|
||||||
fetch(`/admin/api/units/${unitId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ group: moveTargetGroupId })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const failed = results.filter((r) => !r.ok);
|
|
||||||
if (failed.length) {
|
|
||||||
formError = `${failed.length} unit(s) failed to move`;
|
|
||||||
}
|
|
||||||
checkedUnitIds = new Set();
|
|
||||||
moveTargetGroupId = '';
|
|
||||||
await loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCheck(unitId: string) {
|
|
||||||
const next = new Set(checkedUnitIds);
|
|
||||||
if (next.has(unitId)) next.delete(unitId);
|
|
||||||
else next.add(unitId);
|
|
||||||
checkedUnitIds = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Live toBase hint ─────────────────────────────────────────────────────────
|
|
||||||
let toBaseHint = $derived.by(() => {
|
let toBaseHint = $derived.by(() => {
|
||||||
const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
|
const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
|
||||||
if (!g) return null;
|
if (!g) return null;
|
||||||
const base = unitsData.units.find((u) => u.id === g.baseUnitId);
|
const base = unitsData.units.find((u) => u.id === g.baseUnitId);
|
||||||
const sym = unitForm.symbol ?? '?';
|
const sym = unitForm.symbol || '?';
|
||||||
const tb = unitForm.toBase ?? 1;
|
const tb = unitForm.toBase ?? 1;
|
||||||
const baseSymbol = base?.symbol ?? 'base units';
|
const baseSymbol = base?.symbol ?? 'base';
|
||||||
return `1 ${sym} = ${tb} ${baseSymbol}`;
|
return `1 ${sym} = ${tb} ${baseSymbol}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,81 +265,128 @@
|
|||||||
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
|
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if checkedUnitIds.size > 0}
|
|
||||||
<div class="bulk-actions">
|
|
||||||
<span>{checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected</span>
|
|
||||||
<select bind:value={moveTargetGroupId}>
|
|
||||||
<option value="">Move to group…</option>
|
|
||||||
{#each unitsData.groups as g}
|
|
||||||
<option value={g.id}>{g.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button onclick={moveCheckedUnits} disabled={!moveTargetGroupId}>Move</button>
|
|
||||||
<button class="outline secondary" onclick={() => (checkedUnitIds = new Set())}>Clear</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="admin-panels">
|
<div class="admin-panels">
|
||||||
<!-- Left panel: tree -->
|
<!-- ── Left panel ─────────────────────────────────────────────────────────── -->
|
||||||
<aside class="admin-tree">
|
<aside class="admin-tree">
|
||||||
|
|
||||||
|
<!-- Persistent bulk-action bar -->
|
||||||
|
<div class="bulk-bar">
|
||||||
|
<span class="bulk-count">
|
||||||
|
{#if checkedUnitIds.size > 0}
|
||||||
|
{checkedUnitIds.size} selected
|
||||||
|
{:else}
|
||||||
|
0 selected
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="bulk-controls">
|
||||||
|
<select
|
||||||
|
bind:value={bulkAction}
|
||||||
|
disabled={checkedUnitIds.size === 0}
|
||||||
|
aria-label="Bulk action"
|
||||||
|
>
|
||||||
|
<option value="">Action…</option>
|
||||||
|
<option value="move">Move to group</option>
|
||||||
|
</select>
|
||||||
|
{#if bulkAction === 'move'}
|
||||||
|
<select bind:value={bulkMoveTargetGroupId} aria-label="Target group">
|
||||||
|
<option value="">Group…</option>
|
||||||
|
{#each unitsData.groups as g}
|
||||||
|
<option value={g.id}>{g.label}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="__orphan__">Ungrouped</option>
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="bulk-go"
|
||||||
|
disabled={checkedUnitIds.size === 0 || !bulkAction || (bulkAction === 'move' && !bulkMoveTargetGroupId)}
|
||||||
|
onclick={executeBulkAction}
|
||||||
|
>Go</button>
|
||||||
|
{#if checkedUnitIds.size > 0}
|
||||||
|
<button class="bulk-clear plain" onclick={() => { checkedUnitIds = new Set(); bulkAction = ''; bulkMoveTargetGroupId = ''; }} title="Clear selection">✕</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group tree -->
|
||||||
{#each unitsData.groups as group (group.id)}
|
{#each unitsData.groups as group (group.id)}
|
||||||
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
||||||
<div class="tree-group">
|
<div class="tree-group">
|
||||||
<div class="tree-group-header">
|
<div class="tree-group-header">
|
||||||
|
<!-- Expand toggle -->
|
||||||
<button
|
<button
|
||||||
class="tree-expand plain"
|
class="plain expand-btn"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const next = new Set(expandedGroups);
|
const next = new Set(expandedGroups);
|
||||||
if (next.has(group.id)) next.delete(group.id);
|
if (next.has(group.id)) next.delete(group.id);
|
||||||
else next.add(group.id);
|
else next.add(group.id);
|
||||||
expandedGroups = next;
|
expandedGroups = next;
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={expandedGroups.has(group.id) ? 'Collapse' : 'Expand'}
|
||||||
{expandedGroups.has(group.id) ? '▾' : '▸'}
|
>{expandedGroups.has(group.id) ? '▾' : '▸'}</button>
|
||||||
</button>
|
|
||||||
<button class="tree-group-label plain" onclick={() => selectGroup(group)}>
|
<!-- Select-all checkbox -->
|
||||||
|
<input
|
||||||
|
class="group-check"
|
||||||
|
type="checkbox"
|
||||||
|
checked={groupAllChecked(group.id)}
|
||||||
|
indeterminate={groupSomeChecked(group.id)}
|
||||||
|
onchange={() => toggleGroupCheck(group.id)}
|
||||||
|
title="Select all units in this group"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Group name (click to edit) -->
|
||||||
|
<button class="plain tree-group-name" onclick={() => selectGroup(group)}>
|
||||||
<strong>{group.label}</strong>
|
<strong>{group.label}</strong>
|
||||||
<small class="muted">{groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
|
<small class="muted"> · {groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
|
||||||
</button>
|
</button>
|
||||||
<div class="tree-group-actions">
|
|
||||||
<button class="icon-btn plain" title="Edit group" onclick={() => selectGroup(group)}>✏️</button>
|
<!-- Delete only — edit is via click on name -->
|
||||||
<button class="icon-btn plain" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑️</button>
|
<button class="plain icon-btn" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if expandedGroups.has(group.id)}
|
{#if expandedGroups.has(group.id)}
|
||||||
<div class="tree-units">
|
<div class="tree-units">
|
||||||
{#each groupUnits as unit (unit.id)}
|
{#each groupUnits as unit (unit.id)}
|
||||||
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
|
<div
|
||||||
|
class="tree-unit-row"
|
||||||
|
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checkedUnitIds.has(unit.id)}
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
onchange={() => toggleCheck(unit.id)}
|
onchange={() => toggleCheck(unit.id)}
|
||||||
/>
|
/>
|
||||||
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
|
<button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
|
||||||
{unit.label}
|
{unit.label}
|
||||||
<span class="muted">({unit.symbol})</span>
|
<span class="muted">({unit.symbol})</span>
|
||||||
{#if unit.id === group.baseUnitId}
|
{#if unit.id === group.baseUnitId}
|
||||||
<span class="base-badge" title="Base unit">★</span>
|
<span class="base-badge" title="Base unit">★</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn plain" title="Edit" onclick={() => selectUnit(unit)}>✏️</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="add-unit-btn plain" onclick={() => startNewUnit(group.id)}>
|
<button class="plain add-unit-btn" onclick={() => startNewUnit(group.id)}>
|
||||||
+ Add Unit to {group.label}
|
+ Add unit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Ungrouped section -->
|
<!-- Ungrouped -->
|
||||||
{#if (unitsByGroup.get(null) ?? []).length > 0}
|
{#if (unitsByGroup.get(null) ?? []).length > 0}
|
||||||
{@const orphaned = unitsByGroup.get(null) ?? []}
|
{@const orphaned = unitsByGroup.get(null) ?? []}
|
||||||
<div class="tree-group">
|
<div class="tree-group">
|
||||||
<div class="tree-group-header">
|
<div class="tree-group-header">
|
||||||
<span class="tree-group-label"><strong>Ungrouped</strong> <small class="muted">{orphaned.length}</small></span>
|
<input
|
||||||
|
class="group-check"
|
||||||
|
type="checkbox"
|
||||||
|
checked={groupAllChecked(null)}
|
||||||
|
indeterminate={groupSomeChecked(null)}
|
||||||
|
onchange={() => toggleGroupCheck(null)}
|
||||||
|
title="Select all ungrouped units"
|
||||||
|
/>
|
||||||
|
<span class="tree-group-name"><strong>Ungrouped</strong> <small class="muted">· {orphaned.length}</small></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-units">
|
<div class="tree-units">
|
||||||
{#each orphaned as unit (unit.id)}
|
{#each orphaned as unit (unit.id)}
|
||||||
@@ -374,10 +396,9 @@
|
|||||||
checked={checkedUnitIds.has(unit.id)}
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
onchange={() => toggleCheck(unit.id)}
|
onchange={() => toggleCheck(unit.id)}
|
||||||
/>
|
/>
|
||||||
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
|
<button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
|
||||||
{unit.label} <span class="muted">({unit.symbol})</span>
|
{unit.label} <span class="muted">({unit.symbol})</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn plain" onclick={() => selectUnit(unit)}>✏️</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,26 +411,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Right panel: form -->
|
<!-- ── Right panel ────────────────────────────────────────────────────────── -->
|
||||||
<section class="admin-form-panel">
|
<section class="admin-form-panel">
|
||||||
{#if formError}
|
{#if formError}
|
||||||
<p class="form-error" role="alert">{formError}</p>
|
<p class="form-msg form-error" role="alert">{formError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formSuccess}
|
{#if formSuccess}
|
||||||
<p class="form-success" role="status">{formSuccess}</p>
|
<p class="form-msg form-success" role="status">{formSuccess}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (selectedItem?.type === 'unit' || unitFormNew)}
|
{#if selectedItem?.type === 'unit' || unitFormNew}
|
||||||
<!-- Unit form -->
|
|
||||||
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
|
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
|
||||||
<label>
|
<label>
|
||||||
ID {#if !unitFormNew}<small>(readonly)</small>{/if}
|
ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if}
|
||||||
<input
|
<input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit" />
|
||||||
type="text"
|
|
||||||
bind:value={unitForm.id}
|
|
||||||
readonly={!unitFormNew}
|
|
||||||
placeholder="e.g. my-unit"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Label
|
Label
|
||||||
@@ -440,23 +455,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button onclick={saveUnit}>{unitFormNew ? 'Create Unit' : 'Save Unit'}</button>
|
<button onclick={saveUnit}>{unitFormNew ? 'Create' : 'Save'}</button>
|
||||||
{#if !unitFormNew}
|
{#if !unitFormNew}
|
||||||
<button class="outline secondary" onclick={deleteUnit}>Delete</button>
|
<button class="outline secondary" onclick={deleteUnit}>Delete</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if (selectedItem?.type === 'group' || groupFormNew)}
|
{:else if selectedItem?.type === 'group' || groupFormNew}
|
||||||
<!-- Group form -->
|
|
||||||
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
|
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
|
||||||
<label>
|
<label>
|
||||||
ID {#if !groupFormNew}<small>(readonly)</small>{/if}
|
ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if}
|
||||||
<input
|
<input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group" />
|
||||||
type="text"
|
|
||||||
bind:value={groupForm.id}
|
|
||||||
readonly={!groupFormNew}
|
|
||||||
placeholder="e.g. my-group"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Label
|
Label
|
||||||
@@ -464,7 +473,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Base Unit
|
Base Unit
|
||||||
<small>Changing the base unit will auto-recalculate all toBase values in this group.</small>
|
<small class="muted">Changing this auto-recalculates all toBase values in the group.</small>
|
||||||
<select bind:value={groupForm.baseUnitId}>
|
<select bind:value={groupForm.baseUnitId}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
|
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
|
||||||
@@ -474,13 +483,11 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
To Universal
|
To Universal
|
||||||
<small title="Relative scale for cross-group YOLO conversions. Arbitrary — make it up.">
|
<small class="muted">Relative scale for cross-group (YOLO) conversions. Arbitrary.</small>
|
||||||
Relative scale for cross-group conversions.
|
|
||||||
</small>
|
|
||||||
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
|
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button onclick={saveGroup}>{groupFormNew ? 'Create Group' : 'Save Group'}</button>
|
<button onclick={saveGroup}>{groupFormNew ? 'Create' : 'Save'}</button>
|
||||||
{#if !groupFormNew}
|
{#if !groupFormNew}
|
||||||
<button
|
<button
|
||||||
class="outline secondary"
|
class="outline secondary"
|
||||||
@@ -493,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="select-hint">Select a unit or group to edit</p>
|
<p class="select-hint">Select a unit or group to edit, or add a new one.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,21 +514,14 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<article class="modal-card" onclick={(e) => e.stopPropagation()}>
|
<article class="modal-card" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3>Delete "{deleteGroupTarget.label}"</h3>
|
<h3>Delete "{deleteGroupTarget.label}"</h3>
|
||||||
|
|
||||||
{#if unitsCount === 0}
|
{#if unitsCount === 0}
|
||||||
<p>This group has no units. Delete it?</p>
|
<p>This group has no units. Delete it?</p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button
|
<button onclick={confirmDeleteGroup}>Delete Group</button>
|
||||||
onclick={() => {
|
|
||||||
deleteGroupAction = 'orphan';
|
|
||||||
confirmDeleteGroup();
|
|
||||||
}}
|
|
||||||
>Delete Group</button>
|
|
||||||
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
|
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>What to do with these units?</legend>
|
<legend>What to do with these units?</legend>
|
||||||
<label>
|
<label>
|
||||||
@@ -529,24 +529,25 @@
|
|||||||
Reassign to another group
|
Reassign to another group
|
||||||
</label>
|
</label>
|
||||||
{#if deleteGroupAction === 'reassign'}
|
{#if deleteGroupAction === 'reassign'}
|
||||||
<select bind:value={deleteGroupTargetId} style="margin: 0.5rem 0;">
|
<div class="reassign-options">
|
||||||
<option value="">Select target group…</option>
|
<select bind:value={deleteGroupTargetId}>
|
||||||
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
|
<option value="">Select target group…</option>
|
||||||
<option value={g.id}>{g.label}</option>
|
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
|
||||||
{/each}
|
<option value={g.id}>{g.label}</option>
|
||||||
</select>
|
{/each}
|
||||||
<div>
|
</select>
|
||||||
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values</label>
|
<div class="radio-stack">
|
||||||
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate (scale by toUniversal ratio)</label>
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values unchanged</label>
|
||||||
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset to 1.0</label>
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate via toUniversal ratio</label>
|
||||||
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset all toBase to 1.0</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" bind:group={deleteGroupAction} value="orphan" />
|
<input type="radio" bind:group={deleteGroupAction} value="orphan" />
|
||||||
Delete Group, Keep Units (Orphan)
|
Delete group only — keep units as Ungrouped
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button
|
<button
|
||||||
onclick={confirmDeleteGroup}
|
onclick={confirmDeleteGroup}
|
||||||
@@ -568,83 +569,194 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
.admin-header h2 { margin: 0; }
|
||||||
.admin-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--pico-card-background-color);
|
|
||||||
border: 1px solid var(--pico-card-border-color);
|
|
||||||
border-radius: var(--pico-border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panels {
|
.admin-panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 340px 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
@media (max-width: 720px) {
|
|
||||||
.admin-panels { grid-template-columns: 1fr; }
|
.admin-panels { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Left panel ────────────────────────────────────────────────────────────── */
|
||||||
.admin-tree {
|
.admin-tree {
|
||||||
background: var(--pico-card-background-color);
|
background: var(--pico-card-background-color);
|
||||||
border: 1px solid var(--pico-card-border-color);
|
border: 1px solid var(--pico-card-border-color);
|
||||||
border-radius: var(--pico-border-radius);
|
border-radius: var(--pico-border-radius);
|
||||||
padding: 0.75rem;
|
overflow: hidden; /* clean edges */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-group { margin-bottom: 0.5rem; }
|
/* Persistent bulk-action bar */
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--pico-card-border-color);
|
||||||
|
background: var(--pico-secondary-background, rgba(0,0,0,0.03));
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
.bulk-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
.bulk-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.bulk-controls select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
.bulk-go {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
.bulk-clear {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
.bulk-clear:hover { color: var(--pico-color); }
|
||||||
|
|
||||||
|
/* Tree groups */
|
||||||
|
.tree-group {
|
||||||
|
border-bottom: 1px solid var(--pico-card-border-color);
|
||||||
|
}
|
||||||
|
.tree-group:last-of-type { border-bottom: none; }
|
||||||
|
|
||||||
.tree-group-header {
|
.tree-group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-group-label {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-items: baseline;
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-group-actions {
|
.expand-btn {
|
||||||
display: flex;
|
color: var(--pico-muted-color);
|
||||||
gap: 0.25rem;
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree units */
|
||||||
.tree-units {
|
.tree-units {
|
||||||
margin-left: 1.5rem;
|
padding: 0.25rem 0.75rem 0.5rem 2.8rem;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-unit-row {
|
.tree-unit-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
padding: 0.15rem 0.25rem;
|
padding: 0.2rem 0.3rem;
|
||||||
border-radius: var(--pico-border-radius);
|
border-radius: calc(var(--pico-border-radius) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-unit-row.selected {
|
.tree-unit-row.selected {
|
||||||
background: var(--pico-primary-background);
|
background: var(--pico-primary-background);
|
||||||
}
|
}
|
||||||
|
.tree-unit-row input[type=checkbox] {
|
||||||
.tree-unit-label {
|
flex-shrink: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tree-unit-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-unit-btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--pico-card-border-color);
|
||||||
|
}
|
||||||
|
.tree-footer button {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Right panel ───────────────────────────────────────────────────────────── */
|
||||||
|
.admin-form-panel {
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border: 1px solid var(--pico-card-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.select-hint {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.form-msg {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.form-error { color: var(--pico-del-color, #e74c3c); background: rgba(231,76,60,0.1); }
|
||||||
|
.form-success { color: var(--pico-ins-color, #2ecc71); background: rgba(46,204,113,0.1); }
|
||||||
|
.tobase-hint {
|
||||||
|
display: block;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared ────────────────────────────────────────────────────────────────── */
|
||||||
.plain {
|
.plain {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -654,90 +766,13 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.icon-btn { opacity: 0.5; font-size: 0.85rem; }
|
||||||
.icon-btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover { opacity: 1; }
|
.icon-btn:hover { opacity: 1; }
|
||||||
|
.base-badge { color: var(--pico-primary); font-size: 0.75rem; margin-left: 0.2rem; }
|
||||||
|
.muted { color: var(--pico-muted-color); font-size: 0.85rem; }
|
||||||
|
.logout-btn { width: auto; padding: 0.3rem 0.75rem; margin-bottom: 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
.base-badge {
|
/* ── Modal ─────────────────────────────────────────────────────────────────── */
|
||||||
color: var(--pico-primary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: var(--pico-muted-color);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-unit-btn {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--pico-primary);
|
|
||||||
padding: 0.2rem 0.25rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--pico-card-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-panel {
|
|
||||||
background: var(--pico-card-background-color);
|
|
||||||
border: 1px solid var(--pico-card-border-color);
|
|
||||||
border-radius: var(--pico-border-radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-hint {
|
|
||||||
color: var(--pico-muted-color);
|
|
||||||
font-style: italic;
|
|
||||||
margin: 2rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
|
||||||
color: var(--pico-del-color, #e74c3c);
|
|
||||||
background: rgba(231, 76, 60, 0.1);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: var(--pico-border-radius);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-success {
|
|
||||||
color: var(--pico-ins-color, #2ecc71);
|
|
||||||
background: rgba(46, 204, 113, 0.1);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: var(--pico-border-radius);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tobase-hint {
|
|
||||||
display: block;
|
|
||||||
color: var(--pico-muted-color);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn {
|
|
||||||
width: auto;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -747,10 +782,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
.modal-card { width: 100%; max-width: 480px; margin: 0 1rem; }
|
||||||
.modal-card {
|
.reassign-options { margin: 0.5rem 0 0.5rem 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
width: 100%;
|
.radio-stack { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
max-width: 480px;
|
|
||||||
margin: 0 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user