admin: category drag-and-drop reorder + modal-based license key management

- Add sortOrder column to ProclamationCategory with migration
- Implement drag-and-drop reorder in AdminTable with persist-to-DB
- Refactor AdminTable to use $derived + dragOverride (no $effect sync)
- Replace window.location.reload with invalidateAll for reorder

- Convert license key create to modal with subscription level dropdown
- Add LicenseKeyForm.svelte following the CategoryForm pattern
- Add replace action (delete + generate new key with same level)
- Remove deactivateOld checkbox; each key has its own level
- Use use:enhance with update() to refresh list after create/replace
This commit is contained in:
NeilClack
2026-07-08 12:59:55 -05:00
parent 93d941569e
commit 6266be0edf
31 changed files with 21102 additions and 23217 deletions
+78 -4
View File
@@ -8,16 +8,20 @@
searchFields = [],
fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record',
draggable = false,
onRowClick,
onNew
onNew,
onReorder
}: {
records: Record<string, unknown>[];
listFields: string[];
searchFields?: string[];
fieldTypes?: Record<string, PrismaScalarType>;
singular?: string;
draggable?: boolean;
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
@@ -48,6 +52,15 @@
: filtered
);
let isDragging = $state(false);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let rows = $derived(dragOverride ?? sorted);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
function toggleSort(field: string) {
if (sortCol === field) {
sortAsc = !sortAsc;
@@ -57,6 +70,45 @@
}
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = sorted;
isDragging = true;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function formatCellValue(v: unknown, field: string): string {
if (v == null) return '';
const ft = fieldTypes[field];
@@ -100,6 +152,9 @@
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
{#if draggable}
<th scope="col" class="w-10 px-2 py-2"></th>
{/if}
{#each listFields as field (field)}
<th
scope="col"
@@ -115,11 +170,30 @@
</tr>
</thead>
<tbody>
{#each sorted as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
<tr
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
class="cursor-pointer border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
class="border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 {draggable &&
isDragging &&
dragIndex === i
? 'opacity-40'
: ''} {draggable && isDragging && hoverIndex === i
? 'border-t-2 border-t-blue-500'
: ''}"
>
{#if draggable}
<td
class="w-10 cursor-grab px-2 py-2 text-center"
onclick={(e) => e.stopPropagation()}
>
<span class="text-gray-400 select-none">⠿</span>
</td>
{/if}
{#each listFields as field (field)}
<td class="px-4 py-2 text-sm">
{formatCellValue(resolveFieldValue(record, field), field)}
@@ -129,7 +203,7 @@
{:else}
<tr>
<td
colspan={listFields.length}
colspan={listFields.length + (draggable ? 1 : 0)}
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No {singular}s found.