add categories admin page

This commit is contained in:
NeilClack
2026-05-04 09:40:34 -05:00
parent 1615f07974
commit 218a3a3cb0
13 changed files with 59 additions and 1020 deletions
-119
View File
@@ -1,119 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
import ComboBox from './ComboBox.svelte';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
type RelationOptionsData = Record<string, { label: string; value: string | number }[]>;
type Props = {
fields: string[];
fieldTypes: Record<string, PrismaScalarType>;
relationOptions?: Record<string, RelationOptionConfig>;
relationOptionsData?: RelationOptionsData;
record?: Record<string, unknown>;
form?: Record<string, unknown>;
action: string;
submitLabel?: string;
};
let {
fields,
fieldTypes,
relationOptions = {},
relationOptionsData = {},
record,
form,
action,
submitLabel = 'Save'
}: Props = $props();
$inspect(fields);
function getValue(field: string): unknown {
return form?.[field] ?? record?.[field] ?? '';
}
function isRelation(field: string): boolean {
return field in relationOptions;
}
</script>
<form method="POST" {action} class="space-y-5">
{#if record?.id || form?.id}
<input type="hidden" name="id" value={String(form?.id ?? record?.id)} />
{/if}
{#each fields as field}
{@const type = fieldTypes[field] ?? 'String'}
{@const label = fieldNameToLabel(field)}
<div>
<label for={field} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
{#if isRelation(field)}
<ComboBox
id={field}
name={field}
options={relationOptionsData[field] ?? []}
placeholder="Select {label}"
value={getValue(field) as string | number | undefined}
/>
{:else if type === 'Boolean'}
<input
type="checkbox"
id={field}
name={field}
checked={getValue(field) === true ||
getValue(field) === 'on' ||
getValue(field) === 'true'}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
{:else if type === 'DateTime'}
<input
type="datetime-local"
id={field}
name={field}
value={getValue(field) ? String(getValue(field)).slice(0, 16) : ''}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else if type === 'Int' || type === 'Float'}
{#if field === 'price'}
<input
type="number"
id={field}
name={field}
placeholder="0.99"
value={getValue(field) ?? '0.99'}
step={type === 'Float' ? '0.01' : '1'}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else}
<input
type="number"
id={field}
name={field}
value={getValue(field) ?? ''}
step={type === 'Float' ? '0.01' : '1'}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{/if}
{:else if type === 'String'}
<input
type="text"
id={field}
name={field}
value={getValue(field) ?? ''}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else}
<TipTap name={field} initialContent={String(getValue(field) ?? '')} />
{/if}
</div>
{/each}
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-4 focus:ring-blue-200"
>
{submitLabel}
</button>
</form>
-156
View File
@@ -1,156 +0,0 @@
<script lang="ts">
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
type Props = {
records: Record<string, unknown>[];
listFields: string[];
searchFields: string[];
modelSlug: string;
listOnly?: boolean;
};
let { records, listFields, searchFields, modelSlug, listOnly = false }: Props = $props();
let search = $state('');
let defaultSortKey = $derived(listFields[0] ?? '');
let sortKey = $state('');
let sortDirection = $state<'asc' | 'desc'>('asc');
let effectiveSortKey = $derived(sortKey || defaultSortKey);
function formatCell(value: unknown): string {
if (value == null) return '—';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (value instanceof Date) return value.toLocaleString();
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleString();
}
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(2);
return String(value);
}
const filteredAndSorted = $derived(() => {
let result = records;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter((r) =>
searchFields.some((field) => {
const val = resolveFieldValue(r, field);
return val != null && String(val).toLowerCase().includes(searchLower);
})
);
}
result = [...result].sort((a, b) => {
const aVal = resolveFieldValue(a, effectiveSortKey);
const bVal = resolveFieldValue(b, effectiveSortKey);
const aStr = aVal == null ? '' : String(aVal).toLowerCase();
const bStr = bVal == null ? '' : String(bVal).toLowerCase();
const aNum = Number(aVal);
const bNum = Number(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1;
if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return result;
});
function toggleSort(key: string) {
if (effectiveSortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = 'asc';
}
}
function getSortIndicator(key: string) {
if (effectiveSortKey !== key) return '↕';
return sortDirection === 'asc' ? '↑' : '↓';
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search..."
bind:value={search}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{#if search}
<button
type="button"
onclick={() => (search = '')}
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
Clear
</button>
{/if}
<span class="ml-auto text-sm text-gray-500">
{filteredAndSorted().length} of {records.length}
</span>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
{#each listFields as field}
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort(field)}
>
{fieldNameToLabel(field.includes('.') ? field.split('.')[0] : field)}
<span class="text-xs text-gray-400">{getSortIndicator(field)}</span>
</button>
</th>
{/each}
{#if !listOnly}
<th class="px-4 py-3 font-medium text-gray-700"></th>
{/if}
</tr>
</thead>
<tbody>
{#each filteredAndSorted() as record}
<tr class="border-b border-gray-100 hover:bg-gray-50">
{#each listFields as field}
<td class="px-4 py-3 text-gray-600">
{formatCell(resolveFieldValue(record, field))}
</td>
{/each}
{#if !listOnly}
<td class="px-4 py-3 text-right">
<a
href="/admin/{modelSlug}/{record.id}"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
{/if}
</tr>
{:else}
<tr>
<td colspan={listFields.length + (listOnly ? 0 : 1)} class="px-4 py-8 text-center text-gray-500">
{#if search}
No records match your search
{:else}
No records yet
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -1,61 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
let {
label,
records,
displayFields,
editSlug
}: {
label: string;
records: Record<string, unknown>[];
displayFields: string[];
editSlug?: string;
} = $props();
</script>
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h2 class="mb-2 text-sm font-medium text-gray-700">
{label}
<span class="font-normal text-gray-400">({records.length})</span>
</h2>
{#if records.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-gray-200">
{#each displayFields as field}
<th class="pb-2 pr-4 font-medium text-gray-500">
{fieldNameToLabel(field)}
</th>
{/each}
{#if editSlug}
<th class="pb-2 font-medium text-gray-500"></th>
{/if}
</tr>
</thead>
<tbody>
{#each records as record}
<tr class="border-b border-gray-100 last:border-0">
{#each displayFields as field}
<td class="py-1.5 pr-4 text-gray-900">{record[field] ?? '—'}</td>
{/each}
{#if editSlug}
<td class="py-1.5 text-right">
<a
href="/admin/{editSlug}/{record.id}"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-gray-400">No {label.toLowerCase()} found</p>
{/if}
</div>
@@ -1,39 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
let {
label,
record,
displayFields,
editSlug
}: {
label: string;
record: Record<string, unknown> | null;
displayFields: string[];
editSlug?: string;
} = $props();
</script>
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-700">{label}</h2>
{#if editSlug && record?.id}
<a
href="/admin/{editSlug}/{record.id}"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
{/if}
</div>
{#if record}
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{#each displayFields as field}
<dt class="text-gray-500">{fieldNameToLabel(field)}</dt>
<dd class="text-gray-900">{record[field] ?? '—'}</dd>
{/each}
</dl>
{:else}
<p class="text-sm text-gray-400">No {label.toLowerCase()} found</p>
{/if}
</div>