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
@@ -1,36 +1,52 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import { enhance } from '$app/forms';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { enhance } from '$app/forms';
import LicenseKeyForm from './LicenseKeyForm.svelte';
let { data, form }: PageProps = $props();
let { data }: PageProps = $props();
const config = getModelConfig('license-keys')!;
let showCreateForm = $state(false);
let createOpen = $state(false);
let replaceOpen = $state(false);
let replaceRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let createError = $state('');
$effect(() => {
if (form && 'success' in (form as ActionResult)) {
const result = form as ActionResult;
if (result.success) {
toastMessage = 'License key created and emailed';
function openCreate() {
createOpen = true;
}
function openReplace(record: Record<string, unknown>) {
replaceRecord = record;
replaceOpen = true;
}
function closeCreate() {
createOpen = false;
}
function closeReplace() {
replaceOpen = false;
replaceRecord = null;
}
function replaceEnhance() {
return async ({ result, update }: any) => {
if (result.type === 'success' && result.status === 200) {
closeReplace();
toastMessage = 'License key replaced';
toastType = 'success';
showCreateForm = false;
} else {
createError = result.error ?? 'Failed to create license key';
toastMessage = 'Failed to replace license key';
toastType = 'error';
}
}
});
function toggleForm() {
showCreateForm = !showCreateForm;
createError = '';
await update();
};
}
</script>
@@ -39,70 +55,76 @@
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<button
type="button"
onclick={toggleForm}
onclick={openCreate}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
>
{showCreateForm ? 'Cancel' : 'Create Key +'}
New License Key +
</button>
</div>
{#if showCreateForm}
<form
method="POST"
action="?/createKey"
use:enhance
class="mb-6 border border-gray-200 p-4 dark:border-gray-700"
>
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
Create New License Key
</h2>
{#if createError}
<div class="mb-3 border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
{/if}
<div class="flex flex-col gap-3">
<div>
<label
for="email"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User Email
</label>
<input
type="email"
id="email"
name="email"
required
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
placeholder="user@example.com"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="deactivateOld" class="h-4 w-4" />
Deactivate existing active keys for this user
</label>
<div class="flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Create & Email Key
</button>
</div>
</div>
</form>
{/if}
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openReplace}
/>
</div>
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
</AdminModal>
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
{#if replaceRecord}
<div class="flex flex-col gap-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
</p>
<div
class="rounded border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-1 text-gray-500 dark:text-gray-400">
User: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord['user.name'] ?? '')}</span
>
</div>
<div class="text-gray-500 dark:text-gray-400">
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord.level ?? '')}</span
>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
The existing key will be deleted and a new one with the same subscription level will be
created and emailed.
</p>
<form
method="POST"
action="?/replace"
use:enhance={replaceEnhance}
class="flex justify-end gap-2 pt-2"
>
<input type="hidden" name="id" value={String(replaceRecord.id)} />
<button
type="button"
onclick={closeReplace}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
>
Cancel
</button>
<button
type="submit"
class="border border-amber-300 bg-amber-50 px-4 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
Generate Replacement
</button>
</form>
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}