feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
@@ -42,7 +42,7 @@
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
@@ -66,7 +66,7 @@
for (const group of templateSlots) {
for (const slot of group.slots) {
const template = (form as any)[slot.key];
if (template && slot.key !== 'date_format') {
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
validateSlot(slot.key, template, true);
}
}
@@ -84,6 +84,7 @@
scheduled_assets_message: '',
memory_mode_message: '',
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
let form = $state(defaultForm());
let previewTargetType = $state('telegram');
@@ -103,6 +104,7 @@
]},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
]},
];
@@ -134,6 +136,33 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
function clone(c: any) {
form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' };
delete (form as any).id;
delete (form as any).user_id;
delete (form as any).created_at;
editing = null;
showForm = true;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
function sanitizePreview(html: string): string {
// Allow only Telegram-safe HTML tags, escape everything else
return html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Restore allowed tags
.replace(/&lt;a href="([^"]*)"&gt;/g, '<a href="$1" target="_blank" rel="noopener">')
.replace(/&lt;\/a&gt;/g, '</a>')
.replace(/&lt;b&gt;/g, '<b>').replace(/&lt;\/b&gt;/g, '</b>')
.replace(/&lt;i&gt;/g, '<i>').replace(/&lt;\/i&gt;/g, '</i>')
.replace(/&lt;code&gt;/g, '<code>').replace(/&lt;\/code&gt;/g, '</code>')
.replace(/&lt;pre&gt;/g, '<pre>').replace(/&lt;\/pre&gt;/g, '</pre>');
}
function remove(id: number) {
confirmDelete = {
id,
@@ -198,8 +227,9 @@
{/if}
</div>
</div>
{#if slot.key === 'date_format'}
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
<input bind:value={(form as any)[slot.key]}
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }}
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} />
@@ -211,8 +241,8 @@
{/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 class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm preview-html">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
</div>
{/if}
{/if}
@@ -244,7 +274,7 @@
<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}
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<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>
@@ -255,6 +285,7 @@
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
@@ -306,3 +337,13 @@
{/if}
{/if}
</Modal>
<style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
</style>