feat(immich): multi-time-point scheduling for scheduled/periodic/memory
Replace the single comma-separated text box with an add/remove list of native HH:MM pickers for the Immich scheduled-assets, periodic-summary, and memory slots. The backend already stored comma-separated *_times and scheduled one cron job per time; this makes entering several times per day discoverable and hardens the read/write path. Backend: - services/time_list.py: normalize_time_list (validate / dedup / sort / cap at 24, raising TimeListError) + lenient parse_hhmm_list; the scheduler now uses the shared parser (drops its private copy). - tracking_configs API normalizes *_times on every write (422 on bad input) and rejects enabling a slot whose times list is empty. - scheduler warns when an enabled slot has zero or dropped fire times, restoring the observability lost with the old per-call warning. Frontend: - TimeListEditor.svelte: add/remove native time rows, dedupe + sort on emit, per-day cap, collapses on-screen duplicates, aria-labelled rows; syncs from the value prop only on external changes (untrack guard) so keyboard entry isn't clobbered mid-edit. - Descriptor-driven save guard: an enabled feature section must have at least one time. - i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList. Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and the enabled-implies-times validation.
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Multi-time-point editor — an add/remove list of native HH:MM pickers.
|
||||
*
|
||||
* Used in the tracking-configs form for the Immich scheduled / periodic /
|
||||
* memory slots, which fire at one or more wall-clock times per day. The
|
||||
* value crosses the wire as a single comma-separated string ("09:00,18:30")
|
||||
* matching the backend `normalize_time_list`: each emitted value is
|
||||
* de-duplicated and sorted ascending.
|
||||
*
|
||||
* Internally `rows` is the rendering source of truth and preserves the
|
||||
* user's current edit state (including a blank, not-yet-filled row) so the
|
||||
* list never jumps around mid-edit. Normalisation is applied only to the
|
||||
* emitted value, not to what's on screen; the on-screen order re-settles to
|
||||
* sorted form on the next external load.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
/** Comma-separated HH:MM list, e.g. "09:00,18:30". */
|
||||
value: string;
|
||||
/** Called with a normalised (deduped, sorted) comma-separated string. */
|
||||
onchange: (value: string) => void;
|
||||
/** Maximum number of distinct times allowed. */
|
||||
max?: number;
|
||||
/** Placeholder forwarded to each native time input. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { value, onchange, max = 24, placeholder = '' }: Props = $props();
|
||||
|
||||
let rows = $state<string[]>([]);
|
||||
|
||||
// The exact string we last synced-from or emitted. The resync effect below
|
||||
// compares against this so it fires ONLY for genuine external `value` changes
|
||||
// (form reset, config switch) — never for the user's own keystrokes. Without
|
||||
// this guard the effect re-ran on every `bind:value` mutation while `value`
|
||||
// still held the old committed string, clobbering the row being typed in (the
|
||||
// native picker reset just as the user reached the AM/PM segment).
|
||||
let lastValue = '';
|
||||
|
||||
function parse(raw: string): string[] {
|
||||
return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Distinct, non-blank times, in first-seen order. */
|
||||
function distinct(list: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const r of list) {
|
||||
const v = r.trim();
|
||||
if (v) seen.add(v);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// Populate on mount and re-sync on external `value` changes only. `value` is
|
||||
// the sole reactive dependency; `rows` is read/written inside `untrack`, so
|
||||
// editing a row never re-triggers this and can't clobber an in-progress edit.
|
||||
// `$effect.pre` runs before the first paint, so initial rows show without a
|
||||
// flash of the empty state. HH:MM is zero-padded, so a lexical sort is
|
||||
// chronological.
|
||||
$effect.pre(() => {
|
||||
const incoming = value ?? '';
|
||||
untrack(() => {
|
||||
if (incoming !== lastValue) {
|
||||
rows = parse(incoming);
|
||||
lastValue = incoming;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function emit(): void {
|
||||
const next = distinct(rows).sort().join(',');
|
||||
// Record before emitting so the value round-trip back through the parent
|
||||
// isn't mistaken for an external change and used to reset `rows`.
|
||||
lastValue = next;
|
||||
onchange(next);
|
||||
}
|
||||
|
||||
// Fired when a row's picker changes. Collapse any on-screen duplicate of an
|
||||
// already-present time (keeping the first occurrence and any blank
|
||||
// in-progress row) so the displayed rows match what's persisted — emit()
|
||||
// dedups the saved value, and without this the screen would keep showing two
|
||||
// identical rows. Then emit the canonical value.
|
||||
function onRowChange(): void {
|
||||
const seen = new Set<string>();
|
||||
const next: string[] = [];
|
||||
for (const r of rows) {
|
||||
const v = r.trim();
|
||||
if (!v) { next.push(r); continue; }
|
||||
if (seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
next.push(r);
|
||||
}
|
||||
if (next.length !== rows.length) rows = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
const filledCount = $derived(distinct(rows).length);
|
||||
const hasBlankRow = $derived(rows.some((r) => !r.trim()));
|
||||
const atMax = $derived(filledCount >= max);
|
||||
// Block adding while a blank row is open (fill it first) or the cap is hit —
|
||||
// keeps the list from stacking empty rows and respects the per-day limit.
|
||||
const canAdd = $derived(!atMax && !hasBlankRow);
|
||||
|
||||
function addRow(): void {
|
||||
if (!canAdd) return;
|
||||
rows = [...rows, ''];
|
||||
}
|
||||
|
||||
function removeRow(index: number): void {
|
||||
rows = rows.filter((_, i) => i !== index);
|
||||
emit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="time-list">
|
||||
{#each rows as _, i (i)}
|
||||
<div class="time-row">
|
||||
<input
|
||||
type="time"
|
||||
bind:value={rows[i]}
|
||||
onchange={onRowChange}
|
||||
{placeholder}
|
||||
aria-label={t('trackingConfig.timeRowLabel').replace('{n}', String(i + 1))}
|
||||
class="time-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="time-remove"
|
||||
aria-label={t('trackingConfig.removeTime')}
|
||||
onclick={() => removeRow(i)}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if rows.length === 0}
|
||||
<p class="time-empty">
|
||||
<MdiIcon name="mdiClockOutline" size={12} />
|
||||
<span>{t('trackingConfig.noTimes')}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if atMax}
|
||||
<p class="time-cap">{t('trackingConfig.maxTimesReached').replace('{n}', String(max))}</p>
|
||||
{:else}
|
||||
<button type="button" class="time-add" disabled={!canAdd} onclick={addRow}>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
<span>{t('trackingConfig.addTime')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.time-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.time-remove:hover {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.time-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3125rem;
|
||||
width: 100%;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.time-add:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.time-add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.time-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.time-cap {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -728,8 +728,13 @@
|
||||
"deleted": "deleted",
|
||||
"providerType": "Provider Type",
|
||||
"sortRandom": "Random",
|
||||
"timesInlineHelp": "HH:MM, comma-separated",
|
||||
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
|
||||
"timesInlineHelp": "One or more times per day",
|
||||
"addTime": "Add time",
|
||||
"removeTime": "Remove time",
|
||||
"timeRowLabel": "Time {n}",
|
||||
"noTimes": "No times set — add at least one",
|
||||
"maxTimesReached": "Maximum {n} times reached",
|
||||
"timesRequiredFor": "Add at least one time for \"{slot}\"",
|
||||
"previewTemplate": "Preview template",
|
||||
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
|
||||
"editTemplate": "Edit template",
|
||||
@@ -1011,7 +1016,7 @@
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
|
||||
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
|
||||
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
||||
"times": "Time(s) of day to send notifications. Add as many time points per day as you need.",
|
||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
||||
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
|
||||
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
|
||||
|
||||
@@ -728,8 +728,13 @@
|
||||
"deleted": "удалён",
|
||||
"providerType": "Тип провайдера",
|
||||
"sortRandom": "Случайный",
|
||||
"timesInlineHelp": "ЧЧ:ММ, через запятую",
|
||||
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
|
||||
"timesInlineHelp": "Одно или несколько значений времени в день",
|
||||
"addTime": "Добавить время",
|
||||
"removeTime": "Удалить время",
|
||||
"timeRowLabel": "Время {n}",
|
||||
"noTimes": "Время не задано — добавьте хотя бы одно",
|
||||
"maxTimesReached": "Достигнут максимум: {n}",
|
||||
"timesRequiredFor": "Добавьте хотя бы одно время для «{slot}»",
|
||||
"previewTemplate": "Предпросмотр шаблона",
|
||||
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
|
||||
"editTemplate": "Редактировать шаблон",
|
||||
@@ -1011,7 +1016,7 @@
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
||||
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||
"times": "Время отправки уведомлений. Добавьте сколько угодно значений времени в день.",
|
||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
||||
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||
|
||||
@@ -68,14 +68,14 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
fields: [
|
||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
|
||||
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
|
||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
||||
enabledField: 'scheduled_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
|
||||
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
@@ -87,7 +87,7 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
||||
enabledField: 'memory_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
|
||||
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
|
||||
@@ -67,7 +67,8 @@ export interface ExtraTrackingField {
|
||||
* - `toggle` — on/off switch
|
||||
* - `date` — HTML date picker (YYYY-MM-DD)
|
||||
* - `time` — HTML time picker (HH:MM)
|
||||
* - `time-list` — comma-separated HH:MM list, validated on blur
|
||||
* - `time-list` — add/remove list of HH:MM pickers (TimeListEditor),
|
||||
* serialized as a comma-separated string
|
||||
*/
|
||||
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
@@ -78,8 +79,6 @@ export interface ExtraTrackingField {
|
||||
inlineHelp?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** For time-list: show live validation + auto-normalize on blur. */
|
||||
validateFormat?: boolean;
|
||||
/**
|
||||
* Default value. Can be a function for dynamic values (e.g. today's date)
|
||||
* evaluated each time the form is reset.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import TimeListEditor from '$lib/components/TimeListEditor.svelte';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
@@ -34,44 +35,12 @@
|
||||
};
|
||||
|
||||
/**
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||
* are surfaced inline next to the input.
|
||||
* Max distinct dispatch times per slot — mirrors the backend cap
|
||||
* (`MAX_DISPATCH_TIMES` in services/time_list.py). The TimeListEditor
|
||||
* disables "+ Add time" once this many rows are filled; the server enforces
|
||||
* the same limit on write.
|
||||
*/
|
||||
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
||||
|
||||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||
let timeListErrors = $state<Record<string, string>>({});
|
||||
|
||||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||
function normalizeTimeList(key: string) {
|
||||
const raw = String(form[key] ?? '').trim();
|
||||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||
if (!TIME_LIST_RE.test(raw)) {
|
||||
// Try a lenient normalization: split on commas, zero-pad each part.
|
||||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const fixed: string[] = [];
|
||||
let ok = true;
|
||||
for (const p of parts) {
|
||||
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
|
||||
if (!m) { ok = false; break; }
|
||||
const hh = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
|
||||
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
|
||||
}
|
||||
if (ok) {
|
||||
form[key] = fixed.join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
return;
|
||||
}
|
||||
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
|
||||
return;
|
||||
}
|
||||
// Canonicalise spacing.
|
||||
form[key] = raw.split(',').map(s => s.trim()).join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
}
|
||||
const MAX_DISPATCH_TIMES = 24;
|
||||
|
||||
/**
|
||||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||
@@ -280,6 +249,18 @@
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
// Descriptor-driven guard: an enabled feature section that uses a
|
||||
// time-list must have at least one time, otherwise it saves but the
|
||||
// scheduler creates no cron job and the slot silently never fires.
|
||||
for (const section of descriptor?.featureSections ?? []) {
|
||||
const timeField = section.fields.find((f) => f.type === 'time-list');
|
||||
if (!timeField) continue;
|
||||
if (form[section.enabledField] && !String(form[timeField.key] ?? '').trim()) {
|
||||
const msg = t('trackingConfig.timesRequiredFor').replace('{slot}', t(section.legend));
|
||||
error = msg; snackError(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
@@ -413,22 +394,24 @@
|
||||
</label>
|
||||
{:else if field.type === 'grid-select' && field.gridItems}
|
||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||
{:else}
|
||||
{@const inputType = field.type === 'date' ? 'date'
|
||||
: field.type === 'time' ? 'time'
|
||||
: field.type === 'time-list' ? 'text'
|
||||
: 'number'}
|
||||
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
||||
<input type={inputType}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
|
||||
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
|
||||
{:else if field.type === 'time-list'}
|
||||
<TimeListEditor
|
||||
value={String(form[field.key] ?? '')}
|
||||
onchange={(v) => form[field.key] = v}
|
||||
max={MAX_DISPATCH_TIMES} />
|
||||
{#if field.inlineHelp}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||
{/if}
|
||||
{#if hasError}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
||||
{:else}
|
||||
{@const inputType = field.type === 'date' ? 'date'
|
||||
: field.type === 'time' ? 'time'
|
||||
: 'number'}
|
||||
<input type={inputType}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
placeholder={field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{#if field.inlineHelp}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user