All checks were successful
Validate / Hassfest (push) Successful in 3s
- 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>
300 lines
14 KiB
Svelte
300 lines
14 KiB
Svelte
<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>
|