Files
portal/src/routes/(protected)/admin/proclamations/+page.svelte
T
NeilClack 1d11de8cec Refactor admin to shared generic CRUD; restructure proclamation categories/groups and APIs
- 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
2026-08-27 12:03:40 -05:00

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}