Add schedule page with block editor and proclamation browser
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
||||
import { MINUTE_STEP, formatTime, validateBlock, type BlockSibling } from '$lib/schedule-utils';
|
||||
|
||||
type BrowserCategory = { id: number; name: string };
|
||||
type BrowserGroup = { id: number; name: string };
|
||||
type BrowserProclamation = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
groupId: number | null;
|
||||
};
|
||||
|
||||
let {
|
||||
open = false,
|
||||
mode = 'create',
|
||||
blockId,
|
||||
initialStartMinutes = 9 * 60,
|
||||
initialEndMinutes = 10 * 60,
|
||||
initialProclamationIds = [],
|
||||
categories = [],
|
||||
groups = [],
|
||||
proclamations = [],
|
||||
existingBlocks = [],
|
||||
createAction = '?/createBlock',
|
||||
updateAction = '?/updateBlock',
|
||||
deleteAction = '?/deleteBlock',
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
open?: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
blockId?: string;
|
||||
initialStartMinutes?: number;
|
||||
initialEndMinutes?: number;
|
||||
initialProclamationIds?: string[];
|
||||
categories?: BrowserCategory[];
|
||||
groups?: BrowserGroup[];
|
||||
proclamations?: BrowserProclamation[];
|
||||
existingBlocks?: BlockSibling[];
|
||||
createAction?: string;
|
||||
updateAction?: string;
|
||||
deleteAction?: string;
|
||||
onClose?: () => void;
|
||||
onSaved?: () => void;
|
||||
} = $props();
|
||||
|
||||
let step = $state<'details' | 'browse'>('details');
|
||||
let startHour = $state('9');
|
||||
let startMinute = $state('0');
|
||||
let endHour = $state('10');
|
||||
let endMinute = $state('0');
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let error = $state('');
|
||||
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => String(i));
|
||||
|
||||
function minuteOptions(current: string): string[] {
|
||||
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
|
||||
if (base.includes(current)) return base;
|
||||
return [...base, current].sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
// Re-seed the fields whenever the modal is (re)opened.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
startHour = String(Math.floor(initialStartMinutes / 60));
|
||||
startMinute = String(initialStartMinutes % 60);
|
||||
endHour = String(Math.floor(initialEndMinutes / 60));
|
||||
endMinute = String(initialEndMinutes % 60);
|
||||
selectedIds = [...initialProclamationIds];
|
||||
error = '';
|
||||
step = 'details';
|
||||
});
|
||||
|
||||
const startMinutes = $derived((Number(startHour) * 60 + Number(startMinute)) | 0);
|
||||
const endMinutes = $derived((Number(endHour) * 60 + Number(endMinute)) | 0);
|
||||
|
||||
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
|
||||
|
||||
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
|
||||
const selectedTitles = $derived(
|
||||
selectedIds
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
);
|
||||
|
||||
function validate(): string | null {
|
||||
const result = validateBlock(
|
||||
{ startMinutes, endMinutes, proclamationIds: selectedIds },
|
||||
siblings,
|
||||
{ isCreate: mode === 'create' }
|
||||
);
|
||||
return result.ok ? null : result.error;
|
||||
}
|
||||
|
||||
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
|
||||
const intent = formData.get('intent');
|
||||
if (intent !== 'delete') {
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
error = validationError;
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
error = '';
|
||||
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure') {
|
||||
const data = result.data as { error?: string } | undefined;
|
||||
error = data?.error ?? 'Unable to save block.';
|
||||
await update({ reset: false });
|
||||
return;
|
||||
}
|
||||
if (result.type === 'error') {
|
||||
error = 'Unable to save block.';
|
||||
return;
|
||||
}
|
||||
await update();
|
||||
onSaved?.();
|
||||
};
|
||||
};
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
onkeydown={handleKeydown}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
class="absolute inset-0 h-full w-full cursor-default"
|
||||
onclick={() => onClose?.()}
|
||||
></button>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
|
||||
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{step === 'browse'
|
||||
? 'Select Proclamations'
|
||||
: mode === 'edit'
|
||||
? 'Edit Block'
|
||||
: 'Create Block'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onClose?.()}
|
||||
aria-label="Close"
|
||||
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={mode === 'edit' ? updateAction : createAction}
|
||||
use:enhance={handleSubmit}
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{#if mode === 'edit' && blockId}
|
||||
<input type="hidden" name="id" value={blockId} />
|
||||
{/if}
|
||||
<input type="hidden" name="startMinutes" value={startMinutes} />
|
||||
<input type="hidden" name="endMinutes" value={endMinutes} />
|
||||
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if step === 'details'}
|
||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>Start time</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="Start hour"
|
||||
onchange={(e) => (startHour = e.currentTarget.value)}
|
||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{#each hourOptions as hour (hour)}
|
||||
<option value={hour} selected={startHour === hour}>
|
||||
{hour.padStart(2, '0')}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-slate-400">:</span>
|
||||
<select
|
||||
aria-label="Start minute"
|
||||
onchange={(e) => (startMinute = e.currentTarget.value)}
|
||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{#each minuteOptions(startMinute) as minute (minute)}
|
||||
<option value={minute} selected={startMinute === minute}>
|
||||
{minute.padStart(2, '0')}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>End time</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="End hour"
|
||||
onchange={(e) => (endHour = e.currentTarget.value)}
|
||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{#each hourOptions as hour (hour)}
|
||||
<option value={hour} selected={endHour === hour}>
|
||||
{hour.padStart(2, '0')}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-slate-400">:</span>
|
||||
<select
|
||||
aria-label="End minute"
|
||||
onchange={(e) => (endMinute = e.currentTarget.value)}
|
||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{#each minuteOptions(endMinute) as minute (minute)}
|
||||
<option value={minute} selected={endMinute === minute}>
|
||||
{minute.padStart(2, '0')}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Proclamations ({selectedTitles.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (step = 'browse')}
|
||||
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
|
||||
>
|
||||
Browse proclamations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{formatTime(startMinutes)} – {formatTime(endMinutes)}
|
||||
</p>
|
||||
{#if selectedTitles.length === 0}
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
No proclamations selected.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each selectedTitles as title (title)}
|
||||
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ProclamationBrowser
|
||||
{categories}
|
||||
{groups}
|
||||
{proclamations}
|
||||
{selectedIds}
|
||||
onChange={(ids) => (selectedIds = ids)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
|
||||
>
|
||||
<div>
|
||||
{#if step === 'browse'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (step = 'details')}
|
||||
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{:else if mode === 'edit' && blockId}
|
||||
<button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="delete"
|
||||
formaction={deleteAction}
|
||||
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onClose?.()}
|
||||
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{#if step === 'details'}
|
||||
<button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="save"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
|
||||
>
|
||||
{mode === 'edit' ? 'Save Block' : 'Create Block'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user