- Replace per-model admin forms with the shared generic AdminTable/AdminModal CRUD - Add ProclamationGroup model and admin + API v2 routes (replaces subscription levels) - Drop subscription levels and flatten proclamation categories - Update v2 serializers and seed data accordingly
275 lines
7.4 KiB
Svelte
275 lines
7.4 KiB
Svelte
<script lang="ts">
|
|
import type { PageProps } from './$types';
|
|
import { enhance } from '$app/forms';
|
|
import { Editor } from '@tiptap/core';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Highlight from '@tiptap/extension-highlight';
|
|
import { TextStyle } from '@tiptap/extension-text-style';
|
|
import { Color } from '@tiptap/extension-color';
|
|
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';
|
|
|
|
let { data, form }: PageProps = $props();
|
|
|
|
const config = getModelConfig('proclamations')!;
|
|
const viewerRoleId = $derived(data.adminUser?.roleId ?? 4);
|
|
const isSuper = $derived(viewerRoleId <= 1);
|
|
const canDelete = $derived(viewerRoleId <= 2);
|
|
|
|
let modalOpen = $state(false);
|
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
|
let toastMessage = $state('');
|
|
let fieldErrors = $state<Record<string, string>>({});
|
|
let formError = $state('');
|
|
|
|
let dirty = $state(false);
|
|
let name = $state('');
|
|
let solfeggioFrequencyId = $state('');
|
|
let categoryId = $state('');
|
|
let groupId = $state('');
|
|
|
|
// Inline TipTap editor
|
|
let element = $state<HTMLElement>();
|
|
let editor: Editor | null = null;
|
|
let content = $state('');
|
|
|
|
$effect(() => {
|
|
if (form && 'success' in (form as any)) {
|
|
const result = form as any;
|
|
if (result.success) {
|
|
toastMessage = 'Saved successfully';
|
|
closeModal();
|
|
} else {
|
|
formError = result.error ?? '';
|
|
fieldErrors = result.fieldErrors ?? {};
|
|
}
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const record = editingRecord ?? {};
|
|
name = String(record['name'] ?? '');
|
|
solfeggioFrequencyId = String(record['solfeggioFrequencyId'] ?? '');
|
|
categoryId = String(record['categoryId'] ?? '');
|
|
groupId = String(record['groupId'] ?? '');
|
|
content = typeof record['content'] === 'string' ? (record['content'] as string) : '';
|
|
dirty = false;
|
|
});
|
|
|
|
function markDirty() {
|
|
if (editingRecord?.id) dirty = true;
|
|
}
|
|
|
|
function handleCategoryChange(e: Event) {
|
|
categoryId = (e.target as HTMLSelectElement).value;
|
|
}
|
|
|
|
function handleGroupChange(e: Event) {
|
|
groupId = (e.target as HTMLSelectElement).value;
|
|
}
|
|
|
|
let contentKey = $derived(editingRecord?.id ?? 'new');
|
|
|
|
$effect(() => {
|
|
if (!isSuper || !modalOpen || !element) return;
|
|
const initialContent =
|
|
typeof (editingRecord ?? {})['content'] === 'string'
|
|
? ((editingRecord ?? {})['content'] as string)
|
|
: '';
|
|
|
|
editor = new Editor({
|
|
element,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
bulletList: false,
|
|
orderedList: false
|
|
}),
|
|
TextStyle,
|
|
Color,
|
|
Highlight.configure({ multicolor: true })
|
|
],
|
|
content: initialContent,
|
|
onUpdate: ({ editor }) => {
|
|
content = editor.getHTML();
|
|
}
|
|
});
|
|
|
|
const instance = editor;
|
|
instance.setEditable(true);
|
|
|
|
return () => {
|
|
instance.destroy();
|
|
};
|
|
});
|
|
|
|
function toggleBold() {
|
|
editor?.chain().focus().toggleBold().run();
|
|
}
|
|
function toggleItalic() {
|
|
editor?.chain().focus().toggleItalic().run();
|
|
}
|
|
function toggleHighlight() {
|
|
editor?.chain().focus().toggleHighlight().run();
|
|
}
|
|
|
|
function setColor() {
|
|
const current = editor?.getAttributes('textStyle').color ?? '#000000';
|
|
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
|
|
const idx = colors.indexOf(current);
|
|
const next = colors[(idx + 1) % colors.length];
|
|
editor?.chain().focus().setColor(next).run();
|
|
}
|
|
|
|
function openCreate() {
|
|
editingRecord = null;
|
|
formError = '';
|
|
fieldErrors = {};
|
|
modalOpen = true;
|
|
}
|
|
|
|
function openEdit(record: Record<string, unknown>) {
|
|
editingRecord = record;
|
|
formError = '';
|
|
fieldErrors = {};
|
|
modalOpen = true;
|
|
}
|
|
|
|
function closeModal() {
|
|
modalOpen = false;
|
|
editingRecord = null;
|
|
formError = '';
|
|
fieldErrors = {};
|
|
}
|
|
</script>
|
|
|
|
<div>
|
|
<h1>{config.displayName}</h1>
|
|
<AdminTable
|
|
records={data.records}
|
|
listFields={config.listFields}
|
|
searchFields={config.searchFields}
|
|
fieldTypes={config.fieldTypes}
|
|
singular={config.displayNameSingular}
|
|
onRowClick={isSuper ? openEdit : undefined}
|
|
onNew={isSuper ? openCreate : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<AdminModal
|
|
title={editingRecord
|
|
? `Edit ${config.displayNameSingular}`
|
|
: `Create ${config.displayNameSingular}`}
|
|
open={modalOpen}
|
|
onClose={closeModal}
|
|
{canDelete}
|
|
recordId={editingRecord?.id as string | undefined}
|
|
>
|
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
|
{#if editingRecord?.id}
|
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
|
{/if}
|
|
|
|
{#if formError}
|
|
<div>{formError}</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<div>
|
|
<label for="field-name">Name</label>
|
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
|
{#if fieldErrors.name}
|
|
<p>{fieldErrors.name}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="field-solfeggioFrequencyId">Frequency</label>
|
|
<select
|
|
id="field-solfeggioFrequencyId"
|
|
name="solfeggioFrequencyId"
|
|
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
|
|
>
|
|
<option value="">-- Select Frequency --</option>
|
|
{#each data.relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
|
|
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
|
|
>{opt.label}</option
|
|
>
|
|
{/each}
|
|
</select>
|
|
{#if fieldErrors.solfeggioFrequencyId}
|
|
<p>{fieldErrors.solfeggioFrequencyId}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="field-categoryId">Category</label>
|
|
<select id="field-categoryId" name="categoryId" onchange={handleCategoryChange}>
|
|
<option value="">-- Select Category --</option>
|
|
{#each data.categories as cat (String(cat.id))}
|
|
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if fieldErrors.categoryId}
|
|
<p>{fieldErrors.categoryId}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="field-groupId">Group</label>
|
|
<select id="field-groupId" name="groupId" onchange={handleGroupChange}>
|
|
<option value="">-- Select Group --</option>
|
|
{#each data.groups as group (String(group.id))}
|
|
<option value={group.id} selected={String(group.id) === groupId}>{group.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if fieldErrors.groupId}
|
|
<p>{fieldErrors.groupId}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isSuper}
|
|
<div>
|
|
<label for="field-content">Content</label>
|
|
<div id="field-content">
|
|
{#key contentKey}
|
|
<div>
|
|
<div>
|
|
<button type="button" onclick={toggleBold} title="Bold">B</button>
|
|
<button type="button" onclick={toggleItalic} title="Italic">I</button>
|
|
<button type="button" onclick={toggleHighlight} title="Highlight">
|
|
<span>H</span>
|
|
</button>
|
|
<button type="button" onclick={setColor} title="Cycle text color">A</button>
|
|
</div>
|
|
<div
|
|
bind:this={element}
|
|
data-testid="tiptap-editor"
|
|
role="textbox"
|
|
tabindex="0"
|
|
contenteditable="true"
|
|
></div>
|
|
</div>
|
|
<input type="hidden" name="content" value={content} />
|
|
{/key}
|
|
</div>
|
|
{#if fieldErrors.content}
|
|
<p>{fieldErrors.content}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
|
{editingRecord ? 'Update' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</AdminModal>
|
|
|
|
{#if toastMessage}
|
|
<Toast message={toastMessage} />
|
|
{/if}
|