Files
haos-hacs-immich-album-watcher/frontend/src/routes/template-configs/+page.svelte
alexei.dolgolyov 510463cba6
All checks were successful
Validate / Hassfest (push) Successful in 3s
Move preview button to per-slot in edit form, remove card dropdown
- Preview button always visible next to Variables for each template slot
- Remove \n\n prefix from video_warning default value
- Use conditional {% if video_warning %} with blank line in templates
- Fix all .jinja2 files and inline defaults to match
- Add SVG favicon (camera + notification dot)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:03:35 +03:00

300 lines
14 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function validateSlot(slotKey: string, template: string) {
// Clear previous timer
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
return;
}
// Debounce 800ms
validateTimers[slotKey] = setTimeout(async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
// Live preview: show rendered result when no error
if (res.rendered) {
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
} else {
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
}
} catch {
// Network error, don't show as template error
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
}
}, 800);
}
const defaultForm = () => ({
name: '', description: '', icon: '',
message_assets_added: '',
message_assets_removed: '',
message_album_renamed: '',
message_album_deleted: '',
periodic_summary_message: '',
scheduled_assets_message: '',
memory_mode_message: '',
date_format: '%d.%m.%Y, %H:%M UTC',
video_warning: '\n\n⚠ Note: Videos may not be sent due to Telegram\'s 50 MB file size limit.',
});
let form = $state(defaultForm());
const templateSlots = [
{ group: 'eventMessages', slots: [
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
{ key: 'message_album_renamed', label: 'albumRenamed', rows: 2 },
{ key: 'message_album_deleted', label: 'albumDeleted', rows: 2 },
]},
{ group: 'scheduledMessages', slots: [
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
]},
{ group: 'telegramSettings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'video_warning', label: 'videoWarning', rows: 2 },
]},
];
onMount(load);
async function load() {
try {
[configs, varsRef] = await Promise.all([
api('/template-configs'),
api('/template-configs/variables'),
]);
} catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
try {
if (editing) await api(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
}
async function previewSlot(slotKey: string) {
// Toggle: if already showing, hide it
if (slotPreview[slotKey]) { delete slotPreview[slotKey]; slotPreview = { ...slotPreview }; return; }
const template = (form as any)[slotKey] || '';
if (!template) { slotPreview = { ...slotPreview, [slotKey]: '(empty)' }; return; }
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) });
slotPreview = { ...slotPreview, [slotKey]: res.error ? `Error: ${res.error}` : res.rendered };
} catch (err: any) { slotPreview = { ...slotPreview, [slotKey]: `Error: ${err.message}` }; }
}
async function preview(configId: number, slotKey: string) {
const config = configs.find(c => c.id === configId);
if (!config) return;
const template = config[slotKey] || '';
if (!template) return;
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) });
slotPreview[slotKey + '_' + configId] = res.error ? `Error: ${res.error}` : res.rendered;
} catch (err: any) { slotPreview[slotKey + '_' + configId] = `Error: ${err.message}`; }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
</script>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<div in:slide>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-5">
<div>
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="tpc-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
<input id="tpc-desc" bind:value={form.description} placeholder={t('templateConfig.descriptionPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#each templateSlots as group}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'assetFormatting'}<Hint text={t('hints.assetFormatting')} />{:else if group.group === 'dateLocation'}<Hint text={t('hints.dateLocation')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
<div class="space-y-3 mt-2">
{#each group.slots as slot}
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
<div class="flex items-center gap-2">
<button type="button" onclick={() => previewSlot(slot.key)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.preview')}</button>
{#if varsRef[slot.key]}
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
</div>
</div>
{#if (slot.rows || 2) > 2}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
{#if slotPreview[slot.key]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
<pre class="whitespace-pre-wrap">{slotPreview[slot.key]}</pre>
</div>
{/if}
{:else}
<input bind:value={(form as any)[slot.key]}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
{/if}
</div>
{/each}
</div>
</fieldset>
{/each}
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('templateConfig.noConfigs')}</p></Card>
{:else}
<div class="space-y-3">
{#each configs as config}
<Card hover>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<p class="font-medium">{config.name}</p>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
</div>
{/each}
</div>
{#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.albumFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ album.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.album_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{/if}
</Modal>