91e5cd58e9
Backend: - Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs - Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data - Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified) - Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint) - Fix API key leak: only attach x-api-key header for internal provider URLs - Validate config ownership in tracker_targets create/update - Fix _response() double-emit of created_at in template/tracking configs - Add per-target-link test endpoints (test, test-periodic, test-memory) Frontend: - Fix orphaned provider on test exception in providers/new - Add submitting guard + disabled state to targets save button - Move test buttons from tracker card to per-target-link rows - Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations) - i18n for dashboard timeAgo and event type badges (EN + RU) - Add required attribute to chat select dropdown in targets - Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono - Standardize empty states with centered icon + text across all 6 list pages - Add stagger-children animation class to all list containers - Fix slide transition duration consistency (200ms everywhere) - Standardize border-radius to rounded-md across all form inputs - Fix providers/new page structure (h2 + mb-8 spacing) - Fix tracker card action row overflow (flex-wrap justify-end) - JinjaEditor dark mode reactivity (recreate editor on theme change) - Add aria-labels to mobile nav items - Make ConfirmModal confirm button label/icon configurable - Remove double error reporting on providers page - Add telegram bot edit functionality (name editing via PUT) - i18n for External Domain label on provider forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
309 lines
14 KiB
Svelte
309 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';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.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, immediate = false) {
|
|
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;
|
|
}
|
|
|
|
const doValidate = async () => {
|
|
try {
|
|
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
|
|
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
|
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
|
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
|
if (res.rendered) {
|
|
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
|
|
} else {
|
|
const { [slotKey]: _, ...rest } = slotPreview;
|
|
slotPreview = rest;
|
|
}
|
|
} catch {
|
|
slotErrors = { ...slotErrors, [slotKey]: '' };
|
|
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
|
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
|
}
|
|
};
|
|
if (immediate) { doValidate(); }
|
|
else { validateTimers[slotKey] = setTimeout(doValidate, 800); }
|
|
}
|
|
|
|
function refreshAllPreviews() {
|
|
for (const group of templateSlots) {
|
|
for (const slot of group.slots) {
|
|
const template = (form as any)[slot.key];
|
|
if (template && slot.key !== 'date_format') {
|
|
validateSlot(slot.key, template, true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const defaultForm = () => ({
|
|
provider_type: 'immich', name: '', description: '', icon: '',
|
|
message_assets_added: '',
|
|
message_assets_removed: '',
|
|
message_collection_renamed: '',
|
|
message_collection_deleted: '',
|
|
message_sharing_changed: '',
|
|
periodic_summary_message: '',
|
|
scheduled_assets_message: '',
|
|
memory_mode_message: '',
|
|
date_format: '%d.%m.%Y, %H:%M UTC',
|
|
});
|
|
let form = $state(defaultForm());
|
|
let previewTargetType = $state('telegram');
|
|
|
|
const templateSlots = [
|
|
{ group: 'eventMessages', slots: [
|
|
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
|
|
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
|
|
{ key: 'message_collection_renamed', label: 'albumRenamed', rows: 2 },
|
|
{ key: 'message_collection_deleted', label: 'albumDeleted', rows: 2 },
|
|
{ key: 'message_sharing_changed', label: 'sharingChanged', 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: 'settings', slots: [
|
|
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
|
]},
|
|
];
|
|
|
|
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'); snackError(error); }
|
|
finally { loaded = true; }
|
|
}
|
|
|
|
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; }
|
|
function edit(c: any) {
|
|
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
|
|
slotPreview = {}; slotErrors = {};
|
|
setTimeout(() => refreshAllPreviews(), 100);
|
|
}
|
|
|
|
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();
|
|
snackSuccess(t('snack.templateSaved'));
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
}
|
|
|
|
function remove(id: number) {
|
|
confirmDelete = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
|
catch (err: any) { error = err.message; snackError(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={{ duration: 200 }}>
|
|
<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: string) => 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>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label for="preview-target" class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
|
|
<select id="preview-target" bind:value={previewTargetType} onchange={refreshAllPreviews}
|
|
class="px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value="telegram">Telegram</option>
|
|
<option value="webhook">Webhook</option>
|
|
</select>
|
|
</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 === '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">
|
|
{#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.key === 'date_format'}
|
|
<input bind:value={(form as any)[slot.key]}
|
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
|
{:else}
|
|
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} 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] && !slotErrors[slot.key]}
|
|
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
|
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
|
|
</div>
|
|
{/if}
|
|
{/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>
|
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
|
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
|
|
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
|
|
</div>
|
|
</Card>
|
|
{:else}
|
|
<div class="space-y-3 stagger-children">
|
|
{#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>
|
|
{#if config.user_id === 0}
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
|
{/if}
|
|
</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>
|