- Change /api/v1/license from GET with query params to POST with JSON body - Request body uses license_key and email_address - Response returns key, email_address, subscription_level - Add active check (reject deactivated license keys) - Exclude /api/v1/automations/ from API key auth (GHL webhook) - Add copyableFields prop to AdminTable with clipboard icon - Enable copy button on license key field in license-keys admin page
249 lines
6.8 KiB
Svelte
249 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import type { PrismaScalarType } from '$lib/svelte-admin/types';
|
|
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
|
|
|
|
let {
|
|
records = [],
|
|
listFields = [],
|
|
searchFields = [],
|
|
fieldTypes = {} as Record<string, PrismaScalarType>,
|
|
singular = 'record',
|
|
draggable = false,
|
|
copyableFields = [] as string[],
|
|
onRowClick,
|
|
onNew,
|
|
onReorder
|
|
}: {
|
|
records: Record<string, unknown>[];
|
|
listFields: string[];
|
|
searchFields?: string[];
|
|
fieldTypes?: Record<string, PrismaScalarType>;
|
|
singular?: string;
|
|
draggable?: boolean;
|
|
copyableFields?: string[];
|
|
onRowClick?: (record: Record<string, unknown>) => void;
|
|
onNew?: () => void;
|
|
onReorder?: (ids: (string | number)[]) => void;
|
|
} = $props();
|
|
|
|
let search = $state('');
|
|
let sortCol = $state<string | null>(null);
|
|
let sortAsc = $state(true);
|
|
|
|
let filtered = $derived(
|
|
search
|
|
? records.filter((r) =>
|
|
searchFields.some((f) => {
|
|
const v = resolveFieldValue(r, f);
|
|
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
|
|
})
|
|
)
|
|
: records
|
|
);
|
|
|
|
let sorted = $derived(
|
|
sortCol
|
|
? [...filtered].sort((a, b) => {
|
|
const va = resolveFieldValue(a, sortCol!);
|
|
const vb = resolveFieldValue(b, sortCol!);
|
|
if (va == null) return 1;
|
|
if (vb == null) return -1;
|
|
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
|
|
return sortAsc ? cmp : -cmp;
|
|
})
|
|
: 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;
|
|
} else {
|
|
sortCol = field;
|
|
sortAsc = true;
|
|
}
|
|
}
|
|
|
|
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 copyValue(v: unknown) {
|
|
navigator.clipboard.writeText(String(v ?? ''));
|
|
}
|
|
|
|
function formatCellValue(v: unknown, field: string): string {
|
|
if (v == null) return '';
|
|
const ft = fieldTypes[field];
|
|
if (ft === 'DateTime') {
|
|
if (v instanceof Date) return v.toLocaleString();
|
|
return new Date(v as string).toLocaleString();
|
|
}
|
|
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
|
|
if (ft === 'Float') {
|
|
const n = Number(v);
|
|
return Number.isNaN(n) ? String(v) : n.toFixed(2);
|
|
}
|
|
return String(v);
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center justify-between gap-4">
|
|
{#if searchFields.length > 0}
|
|
<input
|
|
type="text"
|
|
placeholder="Search {singular}s..."
|
|
bind:value={search}
|
|
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
/>
|
|
{:else}
|
|
<div></div>
|
|
{/if}
|
|
{#if onNew}
|
|
<button
|
|
type="button"
|
|
onclick={onNew}
|
|
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
>
|
|
New {singular} +
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
|
|
<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"
|
|
onclick={() => toggleSort(field)}
|
|
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
{fieldNameToLabel(field)}
|
|
{#if sortCol === field}
|
|
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
|
|
{/if}
|
|
</th>
|
|
{/each}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#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="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">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
{formatCellValue(resolveFieldValue(record, field), field)}
|
|
{#if copyableFields.includes(field)}
|
|
<button
|
|
type="button"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
copyValue(resolveFieldValue(record, field));
|
|
}}
|
|
title="Copy to clipboard"
|
|
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<svg
|
|
class="size-3.5"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
|
|
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</span>
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td
|
|
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.
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|