System picker lists the account's License Keys labelled by System Name (fallback: key). Schedule type selector (Daily|Weekly) with a confirmation before switching a populated schedule (daily->weekly prefills all seven days; otherwise clears). Weekly shows a per-day editor. Blocks use TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next day'), block empty blocks client-side, default dynamic audio on, and show title/category/group only. Adds gap helper text and an informational kiosk-timezone note (browser fallback; no external/paid dependency).
370 lines
12 KiB
Svelte
370 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import type { SubmitFunction } from '@sveltejs/kit';
|
||
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
||
import TimeInput from '$lib/components/TimeInput.svelte';
|
||
import {
|
||
formatTime,
|
||
hmToMinutes,
|
||
isOvernight,
|
||
toHm,
|
||
validateBlockAgainstSchedule,
|
||
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,
|
||
day = null,
|
||
licenseKeyId = '',
|
||
initialStartMinutes = 9 * 60,
|
||
initialEndMinutes = 10 * 60,
|
||
initialProclamationIds = [],
|
||
initialDynamicAudio = true,
|
||
scheduleType = 'daily' as 'daily' | 'weekly' | null,
|
||
categories = [],
|
||
groups = [],
|
||
proclamations = [],
|
||
existingBlocks = [],
|
||
createAction = '?/createBlock',
|
||
updateAction = '?/updateBlock',
|
||
deleteAction = '?/deleteBlock',
|
||
onClose,
|
||
onSaved
|
||
}: {
|
||
open?: boolean;
|
||
mode?: 'create' | 'edit';
|
||
blockId?: string;
|
||
day?: string | null;
|
||
licenseKeyId?: string;
|
||
initialStartMinutes?: number;
|
||
initialEndMinutes?: number;
|
||
initialProclamationIds?: string[];
|
||
initialDynamicAudio?: boolean;
|
||
scheduleType?: 'daily' | 'weekly' | null;
|
||
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');
|
||
// Seeded from the props in the effect below whenever the modal opens.
|
||
let startMinutes = $state(9 * 60);
|
||
let endMinutes = $state(10 * 60);
|
||
let startValid = $state(true);
|
||
let endValid = $state(true);
|
||
let selectedIds = $state<string[]>([]);
|
||
let dynamicAudio = $state(true);
|
||
let error = $state('');
|
||
|
||
function setStart(hm: string) {
|
||
const minutes = hmToMinutes(hm);
|
||
if (minutes !== null) startMinutes = minutes;
|
||
}
|
||
|
||
function setEnd(hm: string) {
|
||
const minutes = hmToMinutes(hm);
|
||
if (minutes !== null) endMinutes = minutes;
|
||
}
|
||
|
||
// Re-seed the fields whenever the modal is (re)opened.
|
||
$effect(() => {
|
||
if (!open) return;
|
||
startMinutes = initialStartMinutes;
|
||
endMinutes = initialEndMinutes;
|
||
startValid = true;
|
||
endValid = true;
|
||
selectedIds = [...initialProclamationIds];
|
||
dynamicAudio = initialDynamicAudio;
|
||
error = '';
|
||
step = 'details';
|
||
});
|
||
|
||
const overnight = $derived(
|
||
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
|
||
);
|
||
|
||
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 {
|
||
if (!startValid || !endValid) return 'Enter a valid start and end time.';
|
||
if (scheduleType === null) return 'Set a schedule type first.';
|
||
const result = validateBlockAgainstSchedule(
|
||
scheduleType,
|
||
{ day, startMinutes, endMinutes, proclamationIds: selectedIds },
|
||
siblings
|
||
);
|
||
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="licenseKeyId" value={licenseKeyId} />
|
||
{#if day}
|
||
<input type="hidden" name="day" value={day} />
|
||
{/if}
|
||
<input type="hidden" name="startMinutes" value={startMinutes} />
|
||
<input type="hidden" name="endMinutes" value={endMinutes} />
|
||
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
||
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
|
||
|
||
<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">
|
||
<TimeInput
|
||
label="Start time"
|
||
value={toHm(startMinutes)}
|
||
onchange={setStart}
|
||
onvalidity={(valid) => (startValid = valid)}
|
||
/>
|
||
|
||
<TimeInput
|
||
label="End time"
|
||
value={toHm(endMinutes)}
|
||
onchange={setEnd}
|
||
onvalidity={(valid) => (endValid = valid)}
|
||
/>
|
||
|
||
<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 class="flex items-center gap-2">
|
||
<label
|
||
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={dynamicAudio}
|
||
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
|
||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
|
||
/>
|
||
Enable Dynamic Audio
|
||
</label>
|
||
<span class="group relative inline-flex">
|
||
<button
|
||
type="button"
|
||
aria-label="What is Dynamic Audio?"
|
||
aria-describedby="dynamic-audio-help"
|
||
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
|
||
>
|
||
?
|
||
</button>
|
||
<span
|
||
id="dynamic-audio-help"
|
||
role="tooltip"
|
||
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
|
||
>
|
||
Dynamic audio will be enabled for this block, while this block is playing.
|
||
This will override any audio selections you have made on your device while
|
||
this block is active.
|
||
</span>
|
||
</span>
|
||
</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)}{overnight
|
||
? ' (ends next day)'
|
||
: ''}
|
||
</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 class="flex items-center gap-3">
|
||
{#if step === 'details' && 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}
|
||
<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>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
{#if step === 'browse'}
|
||
<button
|
||
type="button"
|
||
onclick={() => (step = 'details')}
|
||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
|
||
>
|
||
Accept
|
||
</button>
|
||
{:else}
|
||
<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}
|