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