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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user