feat(immich): per-album scheduled/memory dispatch + template tooling

Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.

Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.

UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.

Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
  aiohttp.ClientSession was passed — broke test dispatch for periodic/
  scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
  language_code instead of using the raw ?locale query param, matching
  the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
  header and the first asset when the multi-album branch is taken.
This commit is contained in:
2026-04-24 19:15:54 +03:00
parent be15463fd2
commit b61394f057
40 changed files with 1235 additions and 224 deletions
+27 -3
View File
@@ -262,7 +262,14 @@
"testPeriodic": "Test periodic summary",
"testScheduled": "Test scheduled assets",
"testMemory": "Test memory / On This Day",
"testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.",
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
"linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.",
"missingLinksTitle": "Albums Missing Public Links",
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
"expired": "Expired",
@@ -552,7 +559,14 @@
"renamed": "renamed",
"deleted": "deleted",
"providerType": "Provider Type",
"sortRandom": "Random"
"sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"previewTemplate": "Preview template",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
"quietHoursZero": "Quiet period is 0 minutes — adjust times",
"nextDay": "next day"
},
"templateConfig": {
"title": "Template Configs",
@@ -598,7 +612,14 @@
"confirmDelete": "Delete this template config?",
"invalidFormat": "Invalid format string",
"filterSlots": "Filter slots...",
"slots": "slots"
"slots": "slots",
"resetToDefault": "Reset to default",
"resetAllToDefaults": "Reset all to defaults",
"resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.",
"resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.",
"resetNoDefault": "No shipped default for this slot.",
"resetApplied": "Reset to default (not saved yet — click Save to persist)",
"deepLinkNoConfig": "No template config found for this provider. Create one first."
},
"templateVars": {
"message_assets_added": {
@@ -715,9 +736,12 @@
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
"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",
"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).",
"minRating": "Only include assets with at least this star rating (0 = no filter).",
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
"assetFormatting": "How individual assets are formatted within notification messages.",
+27 -3
View File
@@ -262,7 +262,14 @@
"testPeriodic": "Тест периодической сводки",
"testScheduled": "Тест запланированных фото",
"testMemory": "Тест воспоминаний",
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
"missingLinksTitle": "Альбомы без публичных ссылок",
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
"expired": "Истёк",
@@ -552,7 +559,14 @@
"renamed": "переименование",
"deleted": "удалён",
"providerType": "Тип провайдера",
"sortRandom": "Случайный"
"sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
"quietHoursZero": "Тихий период 0 минут — скорректируйте время",
"nextDay": "след. день"
},
"templateConfig": {
"title": "Конфигурации шаблонов",
@@ -598,7 +612,14 @@
"confirmDelete": "Удалить эту конфигурацию шаблона?",
"invalidFormat": "Некорректная строка формата",
"filterSlots": "Фильтр слотов...",
"slots": "слотов"
"slots": "слотов",
"resetToDefault": "Сбросить к умолчанию",
"resetAllToDefaults": "Сбросить все к умолчаниям",
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
},
"templateVars": {
"message_assets_added": {
@@ -715,9 +736,12 @@
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
+22 -13
View File
@@ -1,5 +1,12 @@
import type { ProviderDescriptor } from './types';
/**
* Today's date in ISO (YYYY-MM-DD) — used as the default for
* `periodic_start_date` so new configs anchor to "today" rather than a
* hardcoded date that gets further into the past on every release.
*/
const todayIso = (): string => new Date().toISOString().slice(0, 10);
export const immichDescriptor: ProviderDescriptor = {
type: 'immich',
defaultName: 'Immich',
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
enabledField: 'periodic_enabled', enabledDefault: false,
fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
{ 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: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ 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' },
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
@@ -79,21 +86,21 @@ 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: 'number', defaultValue: '09:00' },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 },
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ 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' },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
],
},
{
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
],
},
],
@@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = {
const warnings: { id: string; name: string; issue: string }[] = [];
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
// album set doesn't stall the save button for seconds. Cap of 6 keeps
// the save dialog responsive for users with 50+ albums while staying
// well under typical Immich per-IP rate limits.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
+5 -2
View File
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
*/
export function buildTrackingFormDefaults(): Record<string, any> {
const defaults: Record<string, any> = {};
// `defaultValue` may be a function (for time-sensitive defaults like
// today's date) so the computed value is fresh each time the form resets.
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
for (const desc of REGISTRY.values()) {
for (const field of desc.eventFields) {
defaults[field.key] = field.default;
}
for (const extra of desc.extraTrackingFields ?? []) {
defaults[extra.key] = extra.defaultValue ?? '';
defaults[extra.key] = resolve(extra.defaultValue) ?? '';
}
for (const section of desc.featureSections ?? []) {
defaults[section.enabledField] = section.enabledDefault;
for (const f of section.fields) {
defaults[f.key] = f.defaultValue ?? '';
defaults[f.key] = resolve(f.defaultValue) ?? '';
}
for (const cb of section.checkboxes ?? []) {
defaults[cb.key] = cb.default;
+19 -2
View File
@@ -60,14 +60,31 @@ export interface EventTrackingField {
export interface ExtraTrackingField {
key: string;
label: string;
type: 'number' | 'grid-select' | 'toggle';
/**
* Control kind:
* - `number` — numeric spinner
* - `grid-select` — icon-grid chooser (requires `gridItems`)
* - `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
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
hint?: string;
/** Inline helper text rendered under the input (not a tooltip). */
inlineHelp?: string;
min?: number;
max?: number;
defaultValue?: string | number | boolean;
/** 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.
*/
defaultValue?: string | number | boolean | (() => string | number | boolean);
}
/** A feature section like periodic summary, scheduled assets, memory mode. */
@@ -54,6 +54,11 @@
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
@@ -253,6 +258,58 @@
}
}
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
@@ -343,7 +400,7 @@
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
@@ -351,6 +408,14 @@
{loc.toUpperCase()}
</button>
{/each}
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
@@ -381,6 +446,11 @@
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
@@ -472,6 +542,14 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
@@ -84,17 +84,23 @@
let testMenuStyle = $state('');
// Test types: basic is always available; periodic/scheduled/memory only for providers
// that have those notification slots in their capabilities
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
// that have those notification slots in their capabilities AND have the feature
// enabled on the tracker's default TrackingConfig. A disabled feature on the
// default config means cron dispatch won't fire it in production either — so
// the test button would just surface a silent skip.
const allTestTypes: Record<string, {
key: string; icon: string; labelKey: string;
requiredSlot?: string; enabledField?: string;
}> = {
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
};
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived.by(() => {
const base = [allTestTypes.basic];
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
if (!testMenuTrackerId) return base;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return base;
@@ -103,8 +109,18 @@
const caps = allCapabilities[provider.type];
if (!caps) return base;
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
base.push({
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
// When surfaced, the button still renders but is disabled and
// shows *why* — users who land here via the test menu without
// having toggled the feature on Tracking Config see a clear
// pointer to the missing setting instead of a silent failure.
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
});
}
return base;
});
@@ -516,6 +532,15 @@
onclose={() => { linkWarning = null; }}
onautoCreate={autoCreateLinks}
ondismiss={dismissLinkWarning}
onupdate={(remaining) => {
if (!linkWarning) return;
if (remaining.length === 0) {
linkWarning = null;
doSave();
} else {
linkWarning = { ...linkWarning, albums: remaining };
}
}}
/>
<ConfirmModal
@@ -1,17 +1,50 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
interface AlbumIssue { id: string; name: string; issue: string }
interface Props {
linkWarning: { albums: any[]; providerId: number } | null;
linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
linkCreating: boolean;
onclose: () => void;
onautoCreate: () => void;
ondismiss: () => void;
/** Called with the updated warning list after a per-row replace. */
onupdate?: (albums: AlbumIssue[]) => void;
}
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
/** Per-row loading state for the "Replace" button. */
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
*/
async function replaceOne(album: AlbumIssue) {
if (!linkWarning) return;
replacing = { ...replacing, [album.id]: true };
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
method: 'POST',
body: JSON.stringify({ replace: true }),
});
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} finally {
replacing = { ...replacing, [album.id]: false };
}
}
</script>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
@@ -19,13 +52,26 @@
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('notificationTracker.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
<div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
<div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex-1 min-w-0">
<span class="font-medium truncate block">{album.name}</span>
{#if album.issue === 'password-protected'}
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
{t('notificationTracker.linkPasswordProtectedNote')}
</span>
{/if}
</div>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span>
{#if album.issue === 'expired' || album.issue === 'password-protected'}
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
</button>
{/if}
</div>
{/each}
</div>
@@ -6,7 +6,13 @@
testMenuOpen: string | null;
testMenuStyle: string;
ttTesting: Record<string, string>;
testTypes: { key: string; icon: string; labelKey: string }[];
/**
* When `disabledReason` is set, the button is rendered greyed out with a
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
* Summary in Tracking Config first"). Clicking is blocked — clicking an
* unconfigured test would have surfaced as a silent server-side skip.
*/
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
ontest: (ttId: number, testType: string) => void;
onclose: () => void;
}
@@ -20,18 +26,27 @@
onclick={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
{#each testTypes as tt}
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
{@const blocked = !!tt.disabledReason}
<button
onclick={() => ontest(Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
disabled={busy || blocked}
title={blocked ? t(tt.disabledReason!) : ''}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
{#if blocked}
<MdiIcon name="mdiLock" size={12} />
{/if}
{#if busy}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{#if blocked}
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
{/if}
{/each}
</div>
{/if}
@@ -4,6 +4,7 @@
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers';
@@ -199,6 +200,22 @@
</div>
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href="/tracking-configs" class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
</div>
</div>
{/if}
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button>
@@ -42,6 +42,17 @@
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
/**
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
* single-slot reset (slotKey populated); ``'all'`` confirms a full
* locale-scoped wipe. Split from confirmDelete so the two flows can
* coexist without stomping each other's state mid-dialog.
*/
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
@@ -206,7 +217,40 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
}
/**
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
* other pages (currently the tracking-configs Preview-template modal).
* Picks the first visible config matching ``provider``, opens it in edit
* mode, and pre-expands the target slot. Strips the param from the URL so
* a subsequent reload doesn't reopen the form unexpectedly.
*/
function handleDeepLink() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const slot = params.get('edit_slot');
if (!slot) return;
const provider = params.get('provider') || '';
const target = allTemplateConfigs.find(
c => !provider || c.provider_type === provider,
);
// Strip the deep-link param so reload/back doesn't replay it.
params.delete('edit_slot');
const qs = params.toString();
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
if (!target) {
snackError(t('templateConfig.deepLinkNoConfig'));
return;
}
edit(target);
expandedSlots = new Set([slot]);
// Scroll the slot into view once the form has rendered.
setTimeout(() => {
const el = document.getElementById(`slot-${slot}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
}
function openNew() {
@@ -241,6 +285,65 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
/**
* Ask the user to confirm a reset. The actual fetch+replace runs in
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
* two steps so we can use the app-wide ConfirmModal (consistent look,
* keyboard handling) instead of ``window.confirm`` (blocks the page).
*/
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
// Replace current-locale slots; leave other locales' values untouched.
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
@@ -321,7 +424,7 @@
</div>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
@@ -329,6 +432,14 @@
{loc.toUpperCase()}
</button>
{/each}
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
@@ -361,6 +472,7 @@
{/if}
</div>
{:else}
<div id="slot-{slot.key}">
<CollapsibleSlot
label={slot.key}
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
@@ -379,6 +491,11 @@
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
@@ -397,6 +514,7 @@
{/if}
{/if}
</CollapsibleSlot>
</div>
{/if}
{/each}
</div>
@@ -466,6 +584,14 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
+232 -11
View File
@@ -12,6 +12,9 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import { sanitizePreview } from '$lib/sanitize';
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -22,13 +25,150 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import type { TrackingConfig } from '$lib/types';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
};
/**
* 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.
*/
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]: '' };
}
/**
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
* minutes — adjust times" when start equals end. Handles overnight ranges
* (start > end) correctly.
*/
function quietHoursPreview(start: string, end: string): string {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
const sMin = sh * 60 + sm;
const eMin = eh * 60 + em;
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
const overnight = sMin > eMin;
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
const h = Math.floor(span / 60);
const m = span % 60;
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
const arrow = overnight
? `${start}${end} ${t('trackingConfig.nextDay')}`
: `${start}${end}`;
return `${arrow} (${dur})`;
}
function gotoTemplateConfig(slotName: string) {
// Deep-link to the template configs page: pass the slot as a query
// param (``edit_slot``) so the destination can auto-open the first
// matching config in edit mode and expand that slot. Plain hashes
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
// until a config is being edited.
const u = new URL('/template-configs', window.location.origin);
u.searchParams.set('provider', 'immich');
u.searchParams.set('edit_slot', slotName);
window.location.href = u.toString();
}
/**
* Inline preview of the shipped default template for a scheduled/periodic/
* memory slot. Using the shipped default (not a tracker's current template)
* keeps this scoped to the tracking-config page — which has no concept of
* which TemplateConfig a given tracker uses. Users who want to edit the
* actual config can click "Edit template" in the modal footer.
*
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
* this preview — the user's UI locale (and other previews) are untouched.
*/
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
let previewLoading = $state(false);
let previewLocales = $derived(supportedLocalesCache.items);
async function openTemplatePreview(slotName: string) {
await supportedLocalesCache.fetch();
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
await renderPreviewFor(slotName, initialLocale);
}
async function renderPreviewFor(slotName: string, locale: string) {
previewLoading = true;
try {
const defaults = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
);
const template = defaults?.[slotName]?.[locale];
if (!template) {
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
return;
}
const res = await api<{ rendered?: string; error?: string }>(
'/template-configs/preview-raw',
{
method: 'POST',
body: JSON.stringify({
template,
target_type: 'telegram',
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
}),
},
);
previewModal = {
slotName,
rendered: res?.rendered || '',
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} finally {
previewLoading = false;
}
}
const SLOT_FOR_SECTION: Record<string, string> = {
periodic: 'periodic_summary_message',
scheduled: 'scheduled_assets_message',
memory: 'memory_mode_message',
};
let allConfigs = $derived(trackingConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
@@ -161,10 +301,20 @@
{t(section.legend)}
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
</legend>
<label class="flex items-center gap-2 text-sm mt-1">
<input type="checkbox" bind:checked={form[section.enabledField]} />
{t('trackingConfig.enabled')}
</label>
<div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={form[section.enabledField]} />
{t('trackingConfig.enabled')}
</label>
{#if SLOT_FOR_SECTION[section.key]}
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
disabled={previewLoading}>
<MdiIcon name="mdiEyeOutline" size={14} />
{t('trackingConfig.previewTemplate')}
</button>
{/if}
</div>
{#if form[section.enabledField]}
<div class="grid grid-cols-3 gap-3 mt-3">
{#each section.fields as field (field.key)}
@@ -181,17 +331,32 @@
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
<input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
{@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}
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
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)]'}" />
{#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>
{/if}
{/if}
</div>
{/each}
</div>
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiWeatherNight" size={12} />
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
</p>
{/if}
{/if}
</fieldset>
{/each}
@@ -268,7 +433,63 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<Modal open={previewModal !== null}
title={previewModal ? `${t('trackingConfig.previewTemplate')} ${previewModal.slotName}` : ''}
onclose={() => previewModal = null}>
{#if previewModal}
{#if previewLocales.length > 1}
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each previewLocales as loc}
<button type="button"
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
disabled={previewLoading}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
{loc.toUpperCase()}
</button>
{/each}
</div>
{/if}
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
{t('trackingConfig.previewSampleNote')}
</p>
<!-- Keep the prior rendered/error box mounted while refetching on locale
switch — just dim it. Unmounting and replacing with a small "…"
placeholder caused a one-frame layout jump as the modal shrank and
then re-expanded. -->
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
{#if previewModal.error}
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
{previewModal.error}
</div>
{:else if previewModal.rendered}
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
</div>
{:else}
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);"></div>
{/if}
</div>
<div class="flex gap-2 justify-end mt-3">
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
{t('trackingConfig.editTemplate')}
</button>
<button type="button" onclick={() => previewModal = null}
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
{t('common.close')}
</button>
</div>
{/if}
</Modal>
<style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
.toggle-switch {
position: relative;
display: inline-flex;