Build Admin pages
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
|
||||
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
|
||||
import ComboBox from './ComboBox.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();
|
||||
|
||||
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'}
|
||||
<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"
|
||||
/>
|
||||
{:else}
|
||||
<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"
|
||||
/>
|
||||
{/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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<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('.').pop() ?? field) : 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>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
type Option = { label: string; value: string | number };
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
value?: string | number;
|
||||
};
|
||||
|
||||
let { id, name, options, placeholder = 'Search...', value }: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
let open = $state(false);
|
||||
let highlightIndex = $state(-1);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let listEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
let selectedValue = $state<string | number | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (value != null && value !== '') {
|
||||
selectedValue = value;
|
||||
}
|
||||
});
|
||||
|
||||
let selectedLabel = $derived(() => {
|
||||
if (selectedValue == null || selectedValue === '') return '';
|
||||
const match = options.find((o) => String(o.value) === String(selectedValue));
|
||||
return match?.label ?? '';
|
||||
});
|
||||
|
||||
let filtered = $derived(() => {
|
||||
if (!search) return options;
|
||||
const q = search.toLowerCase();
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function select(opt: Option) {
|
||||
selectedValue = opt.value;
|
||||
search = '';
|
||||
open = false;
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedValue = undefined;
|
||||
search = '';
|
||||
open = true;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
open = true;
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function handleInputBlur(e: FocusEvent) {
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related?.closest(`[data-combobox="${id}"]`)) return;
|
||||
open = false;
|
||||
search = '';
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const items = filtered();
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
highlightIndex = Math.min(highlightIndex + 1, items.length - 1);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIndex = Math.max(highlightIndex - 1, 0);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (open && highlightIndex >= 0 && highlightIndex < items.length) {
|
||||
select(items[highlightIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
open = false;
|
||||
search = '';
|
||||
highlightIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlighted() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listEl?.children[highlightIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" data-combobox={id}>
|
||||
<input type="hidden" {name} value={selectedValue ?? ''} />
|
||||
|
||||
{#if selectedValue != null && selectedValue !== ''}
|
||||
<div
|
||||
class="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer text-left text-gray-900"
|
||||
onclick={() => clear()}
|
||||
>
|
||||
{selectedLabel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 shrink-0 text-gray-400 hover:text-gray-600"
|
||||
onclick={() => clear()}
|
||||
tabindex={-1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
{id}
|
||||
bind:this={inputEl}
|
||||
bind:value={search}
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
|
||||
onfocus={handleInputFocus}
|
||||
onblur={handleInputBlur}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={listEl}
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
|
||||
role="listbox"
|
||||
>
|
||||
{#each filtered() as opt, i}
|
||||
<li role="option" aria-selected={highlightIndex === i}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm {highlightIndex === i
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50'}"
|
||||
onmousedown={(e: MouseEvent) => { e.preventDefault(); select(opt); }}
|
||||
onmouseenter={() => (highlightIndex = i)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-3 py-2 text-sm text-gray-400">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user