$effect re-runs on any reactive state change — loadData/loadConfig both write state, creating an infinite loop that hung the page under load.
1463 lines
53 KiB
Svelte
1463 lines
53 KiB
Svelte
<script lang="ts">
|
|
import { getContext, onMount } from 'svelte';
|
|
import type { Unit, Group, UnitsData } from '$lib/types';
|
|
|
|
const adminPath = getContext<string>('adminPath') ?? 'admin';
|
|
const api = `/${adminPath}/api`;
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
let unitsData = $state<UnitsData>({ units: [], groups: [] });
|
|
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>('');
|
|
let bulkDeleteConfirming = $state(false);
|
|
let expandedGroups = $state<Set<string>>(new Set());
|
|
let deleteGroupTarget = $state<Group | null>(null);
|
|
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
|
|
let deleteGroupTargetId = $state<string>('');
|
|
let deleteGroupToBaseAction = $state<'recalculate' | 'reset' | 'keep'>('keep');
|
|
let formError = $state<string | null>(null);
|
|
let formSuccess = $state<string | null>(null);
|
|
|
|
// Unit form state
|
|
let unitForm = $state<Partial<Unit>>({});
|
|
let unitFormNew = $state(false);
|
|
let unitFormSnapshot = $state<Partial<Unit>>({});
|
|
let unitFieldError = $state<string | null>(null); // which field has error
|
|
|
|
// Group form state
|
|
let groupForm = $state<Partial<Group>>({});
|
|
let groupFormNew = $state(false);
|
|
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);
|
|
|
|
function toKebab(s: string): string {
|
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function onUnitLabelInput() {
|
|
if (!unitIdManuallyEdited) {
|
|
unitForm.id = toKebab(unitForm.label ?? '');
|
|
}
|
|
}
|
|
|
|
function onUnitIdInput() {
|
|
const val = unitForm.id ?? '';
|
|
if (val === '') {
|
|
unitIdManuallyEdited = false;
|
|
unitForm.id = toKebab(unitForm.label ?? '');
|
|
} else {
|
|
unitIdManuallyEdited = true;
|
|
}
|
|
}
|
|
|
|
function onGroupLabelInput() {
|
|
if (!groupIdManuallyEdited) {
|
|
groupForm.id = toKebab(groupForm.label ?? '');
|
|
}
|
|
}
|
|
|
|
function onGroupIdInput() {
|
|
const val = groupForm.id ?? '';
|
|
if (val === '') {
|
|
groupIdManuallyEdited = false;
|
|
groupForm.id = toKebab(groupForm.label ?? '');
|
|
} else {
|
|
groupIdManuallyEdited = true;
|
|
}
|
|
}
|
|
|
|
// ── Drag-to-reorder state ─────────────────────────────────────────────────────
|
|
let dragGroupId = $state<string | null>(null); // group being dragged
|
|
let dragOverGroupId = $state<string | null>(null); // group being hovered over
|
|
let dragUnitId = $state<string | null>(null); // unit being dragged
|
|
let dragUnitGroupId = $state<string | null>(null); // group of unit being dragged
|
|
let dragOverUnitId = $state<string | null>(null); // unit being hovered over
|
|
|
|
// ── Drag handlers: groups ─────────────────────────────────────────────────────
|
|
function onGroupDragStart(e: DragEvent, groupId: string) {
|
|
dragGroupId = groupId;
|
|
e.dataTransfer!.effectAllowed = 'move';
|
|
e.dataTransfer!.setData('text/plain', groupId);
|
|
}
|
|
|
|
function onGroupDragOver(e: DragEvent, groupId: string) {
|
|
if (!dragGroupId || dragGroupId === groupId) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = 'move';
|
|
dragOverGroupId = groupId;
|
|
}
|
|
|
|
function onGroupDragLeave(groupId: string) {
|
|
if (dragOverGroupId === groupId) dragOverGroupId = null;
|
|
}
|
|
|
|
function onGroupDrop(e: DragEvent, targetGroupId: string) {
|
|
e.preventDefault();
|
|
if (!dragGroupId || dragGroupId === targetGroupId) {
|
|
dragGroupId = null;
|
|
dragOverGroupId = null;
|
|
return;
|
|
}
|
|
// Reorder in local state immediately
|
|
const groups = [...unitsData.groups];
|
|
const fromIdx = groups.findIndex((g) => g.id === dragGroupId);
|
|
const toIdx = groups.findIndex((g) => g.id === targetGroupId);
|
|
if (fromIdx === -1 || toIdx === -1) { dragGroupId = null; dragOverGroupId = null; return; }
|
|
const [moved] = groups.splice(fromIdx, 1);
|
|
groups.splice(toIdx, 0, moved);
|
|
unitsData = { ...unitsData, groups };
|
|
dragGroupId = null;
|
|
dragOverGroupId = null;
|
|
// Persist
|
|
fetch(`${api}/order`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ groups: groups.map((g) => g.id) })
|
|
});
|
|
}
|
|
|
|
function onGroupDragEnd() {
|
|
dragGroupId = null;
|
|
dragOverGroupId = null;
|
|
}
|
|
|
|
// ── Drag handlers: units ──────────────────────────────────────────────────────
|
|
function onUnitDragStart(e: DragEvent, unitId: string, groupId: string | null) {
|
|
dragUnitId = unitId;
|
|
dragUnitGroupId = groupId;
|
|
e.dataTransfer!.effectAllowed = 'move';
|
|
e.dataTransfer!.setData('text/plain', unitId);
|
|
}
|
|
|
|
function onUnitDragOver(e: DragEvent, unitId: string, groupId: string | null) {
|
|
if (!dragUnitId || dragUnitId === unitId || dragUnitGroupId !== groupId) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = 'move';
|
|
dragOverUnitId = unitId;
|
|
}
|
|
|
|
function onUnitDragLeave(unitId: string) {
|
|
if (dragOverUnitId === unitId) dragOverUnitId = null;
|
|
}
|
|
|
|
function onUnitDrop(e: DragEvent, targetUnitId: string, groupId: string | null) {
|
|
e.preventDefault();
|
|
if (!dragUnitId || dragUnitId === targetUnitId || dragUnitGroupId !== groupId) {
|
|
dragUnitId = null;
|
|
dragUnitGroupId = null;
|
|
dragOverUnitId = null;
|
|
return;
|
|
}
|
|
// Reorder in local state
|
|
const units = [...unitsData.units];
|
|
const fromIdx = units.findIndex((u) => u.id === dragUnitId);
|
|
const toIdx = units.findIndex((u) => u.id === targetUnitId);
|
|
if (fromIdx === -1 || toIdx === -1) { dragUnitId = null; dragUnitGroupId = null; dragOverUnitId = null; return; }
|
|
const [moved] = units.splice(fromIdx, 1);
|
|
units.splice(toIdx, 0, moved);
|
|
unitsData = { ...unitsData, units };
|
|
const gid = dragUnitId;
|
|
dragUnitId = null;
|
|
dragUnitGroupId = null;
|
|
dragOverUnitId = null;
|
|
// Build ordered IDs for this group
|
|
const groupKey = groupId ?? 'null';
|
|
const groupUnitIds = units.filter((u) => (u.group ?? null) === groupId).map((u) => u.id);
|
|
fetch(`${api}/order`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ units: { [groupKey]: groupUnitIds } })
|
|
});
|
|
}
|
|
|
|
function onUnitDragEnd() {
|
|
dragUnitId = null;
|
|
dragUnitGroupId = null;
|
|
dragOverUnitId = null;
|
|
}
|
|
|
|
// ── Load data ────────────────────────────────────────────────────────────────
|
|
async function loadData() {
|
|
const [res, gres] = await Promise.all([
|
|
fetch(`${api}/units`),
|
|
fetch(`${api}/groups`)
|
|
]);
|
|
if (!res.ok) return;
|
|
const units: Unit[] = await res.json();
|
|
const groups: Group[] = gres.ok ? await gres.json() : [];
|
|
unitsData = { units, groups };
|
|
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
|
|
}
|
|
|
|
onMount(() => { loadData(); loadConfig(); });
|
|
|
|
async function extractError(res: Response, fallback: string): Promise<string> {
|
|
const body = await res.json().catch(() => ({}));
|
|
return (body as Record<string, string>).error ?? fallback;
|
|
}
|
|
|
|
// ── Derived ──────────────────────────────────────────────────────────────────
|
|
let unitsByGroup = $derived.by(() => {
|
|
const map = new Map<string | null, Unit[]>();
|
|
for (const u of unitsData.units) {
|
|
const key = u.group ?? null;
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(u);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
// ── Selection ────────────────────────────────────────────────────────────────
|
|
async function selectUnit(unit: Unit) {
|
|
const ok = await autoSaveCurrentForm();
|
|
if (!ok) return;
|
|
selectedItem = { type: 'unit', id: unit.id };
|
|
unitForm = { ...unit };
|
|
unitFormSnapshot = { ...unit };
|
|
unitFormNew = false;
|
|
groupFormNew = false;
|
|
unitFieldError = null;
|
|
groupFieldError = null;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
async function selectGroup(group: Group) {
|
|
const ok = await autoSaveCurrentForm();
|
|
if (!ok) return;
|
|
selectedItem = { type: 'group', id: group.id };
|
|
groupForm = { ...group };
|
|
groupFormSnapshot = { ...group };
|
|
groupFormNew = false;
|
|
unitFormNew = false;
|
|
unitFieldError = null;
|
|
groupFieldError = null;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
// ── Dirty checks ─────────────────────────────────────────────────────────────
|
|
function isUnitDirty(): boolean {
|
|
if (!unitFormNew && selectedItem === null) return false;
|
|
const s = unitFormSnapshot;
|
|
return (
|
|
unitForm.label !== s.label ||
|
|
unitForm.labelPlural !== s.labelPlural ||
|
|
unitForm.symbol !== s.symbol ||
|
|
unitForm.toBase !== s.toBase ||
|
|
unitForm.group !== s.group ||
|
|
(unitForm.description ?? '') !== (s.description ?? '')
|
|
);
|
|
}
|
|
|
|
function isGroupDirty(): boolean {
|
|
if (!groupFormNew && selectedItem === null) return false;
|
|
const s = groupFormSnapshot;
|
|
return (
|
|
groupForm.label !== s.label ||
|
|
groupForm.id !== s.id
|
|
);
|
|
}
|
|
|
|
// ── Validate unit form ───────────────────────────────────────────────────────
|
|
function validateUnitForm(): { valid: boolean; firstError: string | null } {
|
|
const f = unitForm;
|
|
if (!f.id || !/^[a-z][a-z0-9-]*$/.test(f.id)) return { valid: false, firstError: 'unit-id' };
|
|
if (!f.label) return { valid: false, firstError: 'unit-label' };
|
|
if (!f.labelPlural) return { valid: false, firstError: 'unit-labelPlural' };
|
|
if (!f.symbol) return { valid: false, firstError: 'unit-symbol' };
|
|
if (typeof f.toBase !== 'number' || f.toBase <= 0) return { valid: false, firstError: 'unit-toBase' };
|
|
return { valid: true, firstError: null };
|
|
}
|
|
|
|
function validateGroupForm(): { valid: boolean; firstError: string | null } {
|
|
const f = groupForm;
|
|
if (!f.id || !/^[a-z][a-z0-9-]*$/.test(f.id)) return { valid: false, firstError: 'group-id' };
|
|
if (!f.label) return { valid: false, firstError: 'group-label' };
|
|
return { valid: true, firstError: null };
|
|
}
|
|
|
|
// ── Auto-save current form if dirty ──────────────────────────────────────────
|
|
async function autoSaveCurrentForm(): Promise<boolean> {
|
|
if (unitFormNew || (selectedItem?.type === 'unit')) {
|
|
if (!isUnitDirty()) return true;
|
|
const v = validateUnitForm();
|
|
if (!v.valid) {
|
|
formError = 'Please complete the current form before opening a new one.';
|
|
unitFieldError = v.firstError;
|
|
return false;
|
|
}
|
|
try {
|
|
const res = unitFormNew
|
|
? await fetch(`${api}/units`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(unitForm)
|
|
})
|
|
: await fetch(`${api}/units/${selectedItem?.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(unitForm)
|
|
});
|
|
if (!res.ok) {
|
|
formError = await extractError(res, 'Auto-save failed');
|
|
return false;
|
|
}
|
|
await loadData();
|
|
return true;
|
|
} catch {
|
|
formError = 'Network error during auto-save';
|
|
return false;
|
|
}
|
|
}
|
|
if (groupFormNew || (selectedItem?.type === 'group')) {
|
|
if (!isGroupDirty()) return true;
|
|
const v = validateGroupForm();
|
|
if (!v.valid) {
|
|
formError = 'Please complete the current form before opening a new one.';
|
|
groupFieldError = v.firstError;
|
|
return false;
|
|
}
|
|
try {
|
|
const res = groupFormNew
|
|
? await fetch(`${api}/groups`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(groupForm)
|
|
})
|
|
: await fetch(`${api}/groups/${selectedItem?.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(groupForm)
|
|
});
|
|
if (!res.ok) {
|
|
formError = await extractError(res, 'Auto-save failed');
|
|
return false;
|
|
}
|
|
await loadData();
|
|
return true;
|
|
} catch {
|
|
formError = 'Network error during auto-save';
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function startNewUnit(groupId?: string) {
|
|
const ok = await autoSaveCurrentForm();
|
|
if (!ok) return;
|
|
selectedItem = null;
|
|
unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
|
|
unitFormSnapshot = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
|
|
unitFormNew = true;
|
|
groupFormNew = false;
|
|
unitFieldError = null;
|
|
groupFieldError = null;
|
|
unitIdManuallyEdited = false;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
async function startNewGroup() {
|
|
const ok = await autoSaveCurrentForm();
|
|
if (!ok) return;
|
|
selectedItem = null;
|
|
groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
|
|
groupFormSnapshot = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
|
|
groupFormNew = true;
|
|
unitFormNew = false;
|
|
unitFieldError = null;
|
|
groupFieldError = null;
|
|
groupIdManuallyEdited = false;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
// ── 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;
|
|
// '__orphan__' is the sentinel value for "move to ungrouped" (group: null)
|
|
const targetGroup = bulkMoveTargetGroupId === '__orphan__' ? null : bulkMoveTargetGroupId;
|
|
const results = await Promise.all(
|
|
[...checkedUnitIds].map((unitId) =>
|
|
fetch(`${api}/units/${unitId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ group: targetGroup })
|
|
})
|
|
)
|
|
);
|
|
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();
|
|
}
|
|
}
|
|
|
|
// ── Bulk delete ──────────────────────────────────────────────────────────────
|
|
async function executeBulkDelete() {
|
|
if (checkedUnitIds.size === 0) return;
|
|
formError = null;
|
|
const ids = [...checkedUnitIds];
|
|
const results = await Promise.all(
|
|
ids.map((unitId) => fetch(`${api}/units/${unitId}`, { method: 'DELETE' }))
|
|
);
|
|
const failed = results.filter((r) => !r.ok);
|
|
if (failed.length) formError = `${failed.length} unit(s) failed to delete`;
|
|
checkedUnitIds = new Set();
|
|
bulkAction = '';
|
|
bulkDeleteConfirming = false;
|
|
await loadData();
|
|
}
|
|
|
|
// ── Keyboard shortcuts for forms ─────────────────────────────────────────────
|
|
function discardUnitForm() {
|
|
unitForm = { ...unitFormSnapshot };
|
|
unitFieldError = null;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
function discardGroupForm() {
|
|
groupForm = { ...groupFormSnapshot };
|
|
groupFieldError = null;
|
|
formError = null;
|
|
formSuccess = null;
|
|
}
|
|
|
|
function onUnitFormKeydown(e: KeyboardEvent) {
|
|
// Don't intercept Enter inside textareas
|
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
|
|
e.preventDefault();
|
|
saveUnit();
|
|
} else if (e.key === 'Escape') {
|
|
discardUnitForm();
|
|
}
|
|
}
|
|
|
|
function onGroupFormKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
|
|
e.preventDefault();
|
|
saveGroup();
|
|
} else if (e.key === 'Escape') {
|
|
discardGroupForm();
|
|
}
|
|
}
|
|
|
|
// ── Unit save/delete ─────────────────────────────────────────────────────────
|
|
async function saveUnit() {
|
|
formError = null;
|
|
formSuccess = null;
|
|
unitFieldError = null;
|
|
const v = validateUnitForm();
|
|
if (!v.valid) {
|
|
formError = 'Please fix the highlighted field.';
|
|
unitFieldError = v.firstError;
|
|
return;
|
|
}
|
|
try {
|
|
const res = unitFormNew
|
|
? await fetch(`${api}/units`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(unitForm)
|
|
})
|
|
: await fetch(`${api}/units/${selectedItem?.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(unitForm)
|
|
});
|
|
if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
|
|
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
|
|
if (unitFormNew) {
|
|
const created = (await res.json()) as Unit;
|
|
unitFormNew = false;
|
|
selectedItem = { type: 'unit', id: created.id ?? '' };
|
|
unitForm = { ...created };
|
|
unitFormSnapshot = { ...created };
|
|
} else {
|
|
unitFormSnapshot = { ...unitForm };
|
|
}
|
|
await loadData();
|
|
} catch { formError = 'Network error'; }
|
|
}
|
|
|
|
async function deleteUnit() {
|
|
if (!selectedItem || selectedItem.type !== 'unit') return;
|
|
if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
|
|
try {
|
|
const res = await fetch(`${api}/units/${selectedItem.id}`, { method: 'DELETE' });
|
|
if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
|
|
selectedItem = null;
|
|
unitForm = {};
|
|
await loadData();
|
|
} catch { formError = 'Network error'; }
|
|
}
|
|
|
|
// ── Group save/delete ─────────────────────────────────────────────────────────
|
|
async function saveGroup() {
|
|
formError = null;
|
|
formSuccess = null;
|
|
groupFieldError = null;
|
|
const v = validateGroupForm();
|
|
if (!v.valid) {
|
|
formError = 'Please fix the highlighted field.';
|
|
groupFieldError = v.firstError;
|
|
return;
|
|
}
|
|
try {
|
|
const res = groupFormNew
|
|
? await fetch(`${api}/groups`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(groupForm)
|
|
})
|
|
: await fetch(`${api}/groups/${selectedItem?.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(groupForm)
|
|
});
|
|
if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
|
|
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
|
|
if (groupFormNew) {
|
|
const created = (await res.json()) as Group;
|
|
groupFormNew = false;
|
|
selectedItem = { type: 'group', id: created.id ?? '' };
|
|
groupForm = { ...created };
|
|
groupFormSnapshot = { ...created };
|
|
} else {
|
|
groupFormSnapshot = { ...groupForm };
|
|
}
|
|
await loadData();
|
|
} catch { formError = 'Network error'; }
|
|
}
|
|
|
|
function openDeleteGroup(group: Group) {
|
|
deleteGroupTarget = group;
|
|
deleteGroupAction = 'reassign';
|
|
deleteGroupTargetId = '';
|
|
deleteGroupToBaseAction = 'keep';
|
|
formError = null;
|
|
}
|
|
|
|
function closeDeleteGroup() { deleteGroupTarget = null; }
|
|
|
|
async function confirmDeleteGroup() {
|
|
if (!deleteGroupTarget) return;
|
|
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
|
|
const body: Record<string, unknown> = unitsCount === 0 || deleteGroupAction === 'orphan'
|
|
? { action: 'orphan' }
|
|
: { action: 'reassign', targetGroupId: deleteGroupTargetId, toBaseAction: deleteGroupToBaseAction };
|
|
try {
|
|
const res = await fetch(`${api}/groups/${deleteGroupTarget.id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
|
|
deleteGroupTarget = null;
|
|
selectedItem = null;
|
|
groupForm = {};
|
|
await loadData();
|
|
} catch { formError = 'Network error'; }
|
|
}
|
|
|
|
// ── toBase hint ──────────────────────────────────────────────────────────────
|
|
let toBaseHint = $derived.by(() => {
|
|
const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
|
|
if (!g) return null;
|
|
const base = unitsData.units.find((u) => u.id === g.baseUnitId);
|
|
const sym = unitForm.symbol || '?';
|
|
const tb = unitForm.toBase ?? 1;
|
|
const baseSymbol = base?.symbol ?? 'base';
|
|
return `1 ${sym} = ${tb} ${baseSymbol}`;
|
|
});
|
|
|
|
async function logout() {
|
|
await fetch(`${api}/auth/logout`, { method: 'POST' });
|
|
window.location.href = `/${adminPath}/login`;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Humor Units — Admin</title>
|
|
</svelte:head>
|
|
|
|
<div class="admin-header">
|
|
<h2>Admin</h2>
|
|
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
|
|
</div>
|
|
|
|
<div class="admin-panels">
|
|
<!-- ── Left panel ─────────────────────────────────────────────────────────── -->
|
|
<aside class="admin-tree">
|
|
|
|
<!-- Persistent bulk-action bar -->
|
|
<div class="bulk-bar">
|
|
<!-- Row 1: count + action dropdown + apply/clear -->
|
|
<div class="bulk-row">
|
|
<span class="bulk-count">
|
|
{#if checkedUnitIds.size > 0}
|
|
<strong>{checkedUnitIds.size}</strong> selected
|
|
{:else}
|
|
0 selected
|
|
{/if}
|
|
</span>
|
|
<select
|
|
class="bulk-select"
|
|
bind:value={bulkAction}
|
|
disabled={checkedUnitIds.size === 0}
|
|
aria-label="Bulk action"
|
|
onchange={() => { bulkDeleteConfirming = false; }}
|
|
>
|
|
<option value="">Action…</option>
|
|
<option value="move">Move to group</option>
|
|
<option value="delete">Delete</option>
|
|
</select>
|
|
{#if bulkAction !== 'delete'}
|
|
<button
|
|
class="bulk-go"
|
|
disabled={checkedUnitIds.size === 0 || !bulkAction || (bulkAction === 'move' && !bulkMoveTargetGroupId)}
|
|
onclick={executeBulkAction}
|
|
>Apply</button>
|
|
{/if}
|
|
<button
|
|
class="bulk-clear plain"
|
|
disabled={checkedUnitIds.size === 0}
|
|
onclick={() => { checkedUnitIds = new Set(); bulkAction = ''; bulkMoveTargetGroupId = ''; bulkDeleteConfirming = false; }}
|
|
>Clear</button>
|
|
</div>
|
|
<!-- Row 2: group target dropdown (only rendered when move is selected) -->
|
|
{#if bulkAction === 'move'}
|
|
<div class="bulk-row bulk-row--sub">
|
|
<span class="bulk-sub-label">→ Group:</span>
|
|
<select class="bulk-select" bind:value={bulkMoveTargetGroupId} aria-label="Target group">
|
|
<option value="">Choose group…</option>
|
|
{#each unitsData.groups as g}
|
|
<option value={g.id}>{g.label}</option>
|
|
{/each}
|
|
<option value="__orphan__">Ungrouped</option>
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
<!-- Row 2 (delete): confirmation -->
|
|
{#if bulkAction === 'delete'}
|
|
{#if !bulkDeleteConfirming}
|
|
<div class="bulk-row bulk-row--sub">
|
|
<button class="bulk-go bulk-delete-confirm-btn" onclick={() => { bulkDeleteConfirming = true; }}>
|
|
Delete {checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'}…
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="bulk-row bulk-row--sub bulk-confirm-row">
|
|
<span class="bulk-confirm-label">Delete <strong>{checkedUnitIds.size}</strong> unit{checkedUnitIds.size === 1 ? '' : 's'}?</span>
|
|
<button class="bulk-go bulk-go--danger" onclick={executeBulkDelete}>Confirm Delete</button>
|
|
<button class="plain bulk-cancel" onclick={() => { bulkDeleteConfirming = false; bulkAction = ''; }}>Cancel</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Group tree -->
|
|
{#each unitsData.groups as group (group.id)}
|
|
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
|
<div
|
|
class="tree-group"
|
|
class:drag-over-group={dragOverGroupId === group.id}
|
|
role="listitem"
|
|
draggable="true"
|
|
ondragstart={(e) => onGroupDragStart(e, group.id)}
|
|
ondragover={(e) => onGroupDragOver(e, group.id)}
|
|
ondragleave={() => onGroupDragLeave(group.id)}
|
|
ondrop={(e) => onGroupDrop(e, group.id)}
|
|
ondragend={onGroupDragEnd}
|
|
>
|
|
<div class="tree-group-header" class:dragging={dragGroupId === group.id}>
|
|
<!-- Expand toggle -->
|
|
<button
|
|
class="plain expand-btn"
|
|
onclick={() => {
|
|
const next = new Set(expandedGroups);
|
|
if (next.has(group.id)) next.delete(group.id);
|
|
else next.add(group.id);
|
|
expandedGroups = next;
|
|
}}
|
|
aria-label={expandedGroups.has(group.id) ? 'Collapse' : 'Expand'}
|
|
>{expandedGroups.has(group.id) ? '▾' : '▸'}</button>
|
|
|
|
<!-- 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>
|
|
<small class="muted"> · {groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
|
|
</button>
|
|
|
|
<!-- Delete only — edit is via click on name -->
|
|
<button class="plain icon-btn" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button>
|
|
</div>
|
|
|
|
{#if expandedGroups.has(group.id)}
|
|
<div class="tree-units">
|
|
{#each groupUnits as unit (unit.id)}
|
|
<div
|
|
class="tree-unit-row"
|
|
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
|
|
class:drag-over-unit={dragOverUnitId === unit.id}
|
|
role="listitem"
|
|
draggable="false"
|
|
ondragover={(e) => onUnitDragOver(e, unit.id, unit.group ?? null)}
|
|
ondragleave={() => onUnitDragLeave(unit.id)}
|
|
ondrop={(e) => onUnitDrop(e, unit.id, unit.group ?? null)}
|
|
>
|
|
<!-- Drag handle -->
|
|
<span
|
|
class="drag-handle"
|
|
role="button"
|
|
tabindex="0"
|
|
draggable="true"
|
|
ondragstart={(e) => onUnitDragStart(e, unit.id, unit.group ?? null)}
|
|
ondragend={onUnitDragEnd}
|
|
title="Drag to reorder"
|
|
>⠿</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={checkedUnitIds.has(unit.id)}
|
|
onchange={() => toggleCheck(unit.id)}
|
|
/>
|
|
<button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
|
|
{unit.label}
|
|
<span class="muted">({unit.symbol})</span>
|
|
{#if unit.id === group.baseUnitId}
|
|
<span class="base-badge" title="Base unit">★</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
<button class="plain add-unit-btn" onclick={() => startNewUnit(group.id)}>
|
|
+ Add unit
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Ungrouped -->
|
|
{#if (unitsByGroup.get(null) ?? []).length > 0}
|
|
{@const orphaned = unitsByGroup.get(null) ?? []}
|
|
<div class="tree-group">
|
|
<div class="tree-group-header">
|
|
<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 class="tree-units">
|
|
{#each orphaned as unit (unit.id)}
|
|
<div
|
|
class="tree-unit-row"
|
|
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
|
|
class:drag-over-unit={dragOverUnitId === unit.id}
|
|
role="listitem"
|
|
ondragover={(e) => onUnitDragOver(e, unit.id, null)}
|
|
ondragleave={() => onUnitDragLeave(unit.id)}
|
|
ondrop={(e) => onUnitDrop(e, unit.id, null)}
|
|
>
|
|
<span
|
|
class="drag-handle"
|
|
role="button"
|
|
tabindex="0"
|
|
draggable="true"
|
|
ondragstart={(e) => onUnitDragStart(e, unit.id, null)}
|
|
ondragend={onUnitDragEnd}
|
|
title="Drag to reorder"
|
|
>⠿</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={checkedUnitIds.has(unit.id)}
|
|
onchange={() => toggleCheck(unit.id)}
|
|
/>
|
|
<button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
|
|
{unit.label} <span class="muted">({unit.symbol})</span>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<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>
|
|
|
|
<!-- ── Right panel ────────────────────────────────────────────────────────── -->
|
|
<section class="admin-form-panel">
|
|
{#if formError}
|
|
<p class="form-msg form-error" role="alert">{formError}</p>
|
|
{/if}
|
|
{#if formSuccess}
|
|
<p class="form-msg form-success" role="status">{formSuccess}</p>
|
|
{/if}
|
|
|
|
{#if selectedItem?.type === 'unit' || unitFormNew}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div onkeydown={onUnitFormKeydown}>
|
|
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
|
|
<label>
|
|
ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if}
|
|
<input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit"
|
|
class:field-error={unitFieldError === 'unit-id'}
|
|
oninput={() => unitFormNew && onUnitIdInput()} />
|
|
</label>
|
|
<label>
|
|
Label
|
|
<input type="text" bind:value={unitForm.label} placeholder="e.g. My Unit"
|
|
class:field-error={unitFieldError === 'unit-label'}
|
|
oninput={() => unitFormNew && onUnitLabelInput()} />
|
|
</label>
|
|
<label>
|
|
Label (plural)
|
|
<input type="text" bind:value={unitForm.labelPlural} placeholder="e.g. My Units"
|
|
class:field-error={unitFieldError === 'unit-labelPlural'} />
|
|
</label>
|
|
<label>
|
|
Symbol
|
|
<input type="text" bind:value={unitForm.symbol} placeholder="e.g. mu"
|
|
class:field-error={unitFieldError === 'unit-symbol'} />
|
|
</label>
|
|
<label>
|
|
Group
|
|
<select bind:value={unitForm.group}>
|
|
<option value={null}>Ungrouped</option>
|
|
{#each unitsData.groups as g}
|
|
<option value={g.id}>{g.label}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
To Base
|
|
<input type="number" bind:value={unitForm.toBase} min="0.000001" step="any"
|
|
class:field-error={unitFieldError === 'unit-toBase'} />
|
|
{#if toBaseHint}
|
|
<small class="tobase-hint">{toBaseHint}</small>
|
|
{/if}
|
|
</label>
|
|
<label>
|
|
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>
|
|
</label>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" bind:checked={unitForm.hidden} />
|
|
<span>Don't show on front end</span>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button onclick={saveUnit}>{unitFormNew ? 'Create' : 'Save'}</button>
|
|
{#if !unitFormNew}
|
|
<button class="outline secondary" onclick={deleteUnit}>Delete</button>
|
|
{/if}
|
|
</div>
|
|
</div><!-- /onkeydown unit form -->
|
|
|
|
{:else if selectedItem?.type === 'group' || groupFormNew}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div onkeydown={onGroupFormKeydown}>
|
|
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
|
|
<label>
|
|
ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if}
|
|
<input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group"
|
|
class:field-error={groupFieldError === 'group-id'}
|
|
oninput={() => groupFormNew && onGroupIdInput()} />
|
|
</label>
|
|
<label>
|
|
Label
|
|
<input type="text" bind:value={groupForm.label} placeholder="e.g. My Group"
|
|
class:field-error={groupFieldError === 'group-label'}
|
|
oninput={() => groupFormNew && onGroupLabelInput()} />
|
|
</label>
|
|
<label>
|
|
Base Unit
|
|
<small class="muted">Changing this auto-recalculates all toBase values in the group.</small>
|
|
<select bind:value={groupForm.baseUnitId}>
|
|
<option value="">None</option>
|
|
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
|
|
<option value={u.id}>{u.label} ({u.symbol})</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
To Universal
|
|
<small class="muted">Relative scale for cross-group (YOLO) conversions. Arbitrary.</small>
|
|
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
|
|
</label>
|
|
<label>
|
|
Display order
|
|
<small class="muted">How units in this group are sorted in the converter.</small>
|
|
<select bind:value={groupForm.sortOrder}>
|
|
<option value={undefined}>Defined order (default)</option>
|
|
<option value="defined">Defined order</option>
|
|
<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>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button onclick={saveGroup}>{groupFormNew ? 'Create' : 'Save'}</button>
|
|
{#if !groupFormNew}
|
|
<button
|
|
class="outline secondary"
|
|
onclick={() => {
|
|
const g = unitsData.groups.find((gr) => gr.id === selectedItem?.id);
|
|
if (g) openDeleteGroup(g);
|
|
}}
|
|
>Delete</button>
|
|
{/if}
|
|
</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}
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Delete group modal -->
|
|
{#if deleteGroupTarget}
|
|
{@const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="modal-backdrop" onclick={closeDeleteGroup}>
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<article class="modal-card" onclick={(e) => e.stopPropagation()}>
|
|
<h3>Delete "{deleteGroupTarget.label}"</h3>
|
|
{#if unitsCount === 0}
|
|
<p>This group has no units. Delete it?</p>
|
|
<div class="form-actions">
|
|
<button onclick={confirmDeleteGroup}>Delete Group</button>
|
|
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
|
</div>
|
|
{:else}
|
|
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
|
|
<fieldset>
|
|
<legend>What to do with these units?</legend>
|
|
<label>
|
|
<input type="radio" bind:group={deleteGroupAction} value="reassign" />
|
|
Reassign to another group
|
|
</label>
|
|
{#if deleteGroupAction === 'reassign'}
|
|
<div class="reassign-options">
|
|
<select bind:value={deleteGroupTargetId}>
|
|
<option value="">Select target group…</option>
|
|
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
|
|
<option value={g.id}>{g.label}</option>
|
|
{/each}
|
|
</select>
|
|
<div class="radio-stack">
|
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values unchanged</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>
|
|
{/if}
|
|
<label>
|
|
<input type="radio" bind:group={deleteGroupAction} value="orphan" />
|
|
Delete group only — keep units as Ungrouped
|
|
</label>
|
|
</fieldset>
|
|
<div class="form-actions">
|
|
<button
|
|
onclick={confirmDeleteGroup}
|
|
disabled={deleteGroupAction === 'reassign' && !deleteGroupTargetId}
|
|
>
|
|
{deleteGroupAction === 'reassign' ? 'Reassign & Delete Group' : 'Delete Group, Keep Units'}
|
|
</button>
|
|
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
|
</div>
|
|
{/if}
|
|
</article>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.admin-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.admin-header h2 { margin: 0; }
|
|
|
|
.admin-panels {
|
|
display: grid;
|
|
grid-template-columns: 340px 1fr;
|
|
gap: 1.5rem;
|
|
align-items: start;
|
|
}
|
|
@media (max-width: 800px) {
|
|
.admin-panels { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
/* ── Left panel ────────────────────────────────────────────────────────────── */
|
|
.admin-tree {
|
|
background: var(--pico-card-background-color);
|
|
border: 1px solid var(--pico-card-border-color);
|
|
border-radius: var(--pico-border-radius);
|
|
overflow: hidden; /* clean edges */
|
|
}
|
|
|
|
/* Persistent bulk-action bar */
|
|
.bulk-bar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 2px solid var(--pico-primary-border, #4a6fa5);
|
|
background: var(--pico-card-background-color);
|
|
}
|
|
.bulk-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
.bulk-row--sub {
|
|
padding-left: 0.25rem;
|
|
}
|
|
.bulk-count {
|
|
font-size: 0.8rem;
|
|
color: var(--pico-color);
|
|
white-space: nowrap;
|
|
min-width: 4.5rem;
|
|
}
|
|
.bulk-select {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 0.15rem 0.3rem;
|
|
font-size: 0.8rem;
|
|
margin: 0;
|
|
height: 1.9rem;
|
|
}
|
|
.bulk-go {
|
|
padding: 0.15rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
height: 1.9rem;
|
|
}
|
|
.bulk-clear {
|
|
padding: 0.15rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
height: 1.9rem;
|
|
color: var(--pico-muted-color);
|
|
border: 1px solid var(--pico-muted-border-color);
|
|
border-radius: var(--pico-border-radius);
|
|
background: transparent;
|
|
}
|
|
.bulk-clear:not(:disabled):hover {
|
|
/* Pico's button:hover sets --pico-color:#fff and --pico-background-color to
|
|
primary-hover-background, making the text invisible on light/white backgrounds.
|
|
Override all three explicitly. */
|
|
--pico-color: var(--pico-contrast) !important;
|
|
--pico-background-color: transparent !important;
|
|
--pico-border-color: var(--pico-contrast) !important;
|
|
color: var(--pico-contrast) !important;
|
|
background: transparent !important;
|
|
border-color: var(--pico-contrast) !important;
|
|
text-decoration: underline;
|
|
}
|
|
.bulk-clear:disabled,
|
|
.bulk-go:disabled,
|
|
.bulk-select:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.bulk-sub-label {
|
|
font-size: 0.78rem;
|
|
color: var(--pico-muted-color);
|
|
white-space: nowrap;
|
|
min-width: 4.5rem;
|
|
}
|
|
.bulk-confirm-row {
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
.bulk-confirm-label {
|
|
font-size: 0.8rem;
|
|
color: var(--pico-color);
|
|
white-space: nowrap;
|
|
}
|
|
.bulk-go--danger {
|
|
--pico-background-color: var(--pico-del-color, #e74c3c);
|
|
--pico-border-color: var(--pico-del-color, #e74c3c);
|
|
background: var(--pico-del-color, #e74c3c);
|
|
border-color: var(--pico-del-color, #e74c3c);
|
|
}
|
|
.bulk-cancel {
|
|
font-size: 0.8rem;
|
|
padding: 0.15rem 0.4rem;
|
|
color: var(--pico-muted-color);
|
|
}
|
|
.bulk-delete-confirm-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--pico-del-color, #e74c3c);
|
|
color: var(--pico-del-color, #e74c3c);
|
|
--pico-background-color: transparent;
|
|
--pico-border-color: var(--pico-del-color, #e74c3c);
|
|
--pico-color: var(--pico-del-color, #e74c3c);
|
|
}
|
|
|
|
/* Tree groups */
|
|
.tree-group {
|
|
border-bottom: 1px solid var(--pico-card-border-color);
|
|
}
|
|
.tree-group:last-of-type { border-bottom: none; }
|
|
|
|
.tree-group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
|
|
.expand-btn {
|
|
color: var(--pico-muted-color);
|
|
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 {
|
|
padding: 0.25rem 0.75rem 0.5rem 2.8rem;
|
|
}
|
|
|
|
.tree-unit-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.2rem 0.3rem;
|
|
border-radius: calc(var(--pico-border-radius) / 2);
|
|
}
|
|
.tree-unit-row.selected {
|
|
background: var(--pico-primary-background);
|
|
}
|
|
.tree-unit-row input[type=checkbox] {
|
|
flex-shrink: 0;
|
|
margin: 0;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
cursor: pointer;
|
|
}
|
|
.tree-unit-btn {
|
|
flex: 1;
|
|
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;
|
|
}
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.checkbox-label input[type=checkbox] {
|
|
flex-shrink: 0;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
margin: 0;
|
|
/* Pico adds margin-top to checkboxes — override so flex centering works */
|
|
margin-top: 0 !important;
|
|
margin-bottom: 0 !important;
|
|
}
|
|
.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); }
|
|
.field-error {
|
|
border-color: var(--pico-form-element-invalid-border-color) !important;
|
|
}
|
|
.tobase-hint {
|
|
display: block;
|
|
color: var(--pico-muted-color);
|
|
margin-top: 0.25rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* ── Shared ────────────────────────────────────────────────────────────────── */
|
|
.plain {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0.15rem 0.25rem;
|
|
margin: 0;
|
|
color: inherit;
|
|
font: inherit;
|
|
}
|
|
.icon-btn { opacity: 0.5; font-size: 0.85rem; }
|
|
.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; }
|
|
|
|
/* ── Modal ─────────────────────────────────────────────────────────────────── */
|
|
.modal-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
.modal-card { width: 100%; max-width: 480px; margin: 0 1rem; }
|
|
.reassign-options { margin: 0.5rem 0 0.5rem 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.radio-stack { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
|
|
/* ── Drag-to-reorder ───────────────────────────────────────────────────────── */ .tree-group-header {
|
|
cursor: grab;
|
|
}
|
|
.tree-group-header.dragging {
|
|
cursor: grabbing;
|
|
opacity: 0.5;
|
|
}
|
|
.drag-over-group > .tree-group-header {
|
|
border-top: 2px solid var(--pico-primary);
|
|
}
|
|
.drag-handle {
|
|
cursor: grab;
|
|
color: var(--pico-color);
|
|
opacity: 0.35;
|
|
font-size: 0.9rem;
|
|
padding: 0 0.1rem;
|
|
flex-shrink: 0;
|
|
user-select: none;
|
|
line-height: 1;
|
|
}
|
|
.drag-handle:hover {
|
|
opacity: 0.7;
|
|
}
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
opacity: 1;
|
|
}
|
|
/* On selected/drag-over rows the background changes — boost opacity so dots stay visible. */
|
|
.tree-unit-row.selected .drag-handle,
|
|
.tree-unit-row.drag-over-unit .drag-handle {
|
|
color: var(--pico-primary-inverse);
|
|
opacity: 0.6;
|
|
}
|
|
/* Dan Mode: handle is magenta at full blast; black on yellow selected rows. */
|
|
:global([data-theme=dan]) .drag-handle {
|
|
color: #ff00ff;
|
|
opacity: 1;
|
|
}
|
|
:global([data-theme=dan]) .tree-unit-row.selected .drag-handle,
|
|
:global([data-theme=dan]) .tree-unit-row.drag-over-unit .drag-handle {
|
|
color: #000000;
|
|
opacity: 1;
|
|
}
|
|
.drag-over-unit {
|
|
border-top: 2px solid var(--pico-primary);
|
|
}
|
|
</style>
|