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
+129 -19
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { t, getLocale } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,12 @@
let toggling = $state<Record<number, boolean>>({});
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
let ttTesting = $state<Record<string, string>>({});
let ttFeedback = $state<Record<string, string>>({});
// Shared link validation
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
let linkCheckLoading = $state(false);
let linkCreating = $state(false);
let previousCollectionIds = $state<string[]>([]);
// Tracker form
const defaultForm = () => ({
@@ -65,13 +70,14 @@
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
};
previousCollectionIds = [...(trk.collection_ids || [])];
editing = trk.id; showForm = true;
if (form.provider_id) await loadCollections();
}
@@ -79,6 +85,41 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
if (submitting) return;
// Check shared links for newly added albums
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: any[] = [];
for (const albumId of newAlbumIds) {
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
issue: problematicLink
? (problematicLink.is_expired ? 'expired' : 'password-protected')
: 'missing',
});
}
}
if (missingAlbums.length > 0) {
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
linkCheckLoading = false;
return; // Show warning, don't save yet
}
} catch { /* Proceed if check fails */ }
linkCheckLoading = false;
}
await doSave();
}
async function doSave() {
submitting = true;
try {
if (editing) {
@@ -88,9 +129,34 @@
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; await load();
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
}
async function autoCreateLinks() {
if (!linkWarning) return;
linkCreating = true;
let created = 0;
for (const album of linkWarning.albums) {
if (album.issue === 'missing') {
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
}
}
}
if (created > 0) snackSuccess(`Created ${created} public link(s)`);
linkWarning = null;
linkCreating = false;
await doSave();
}
function dismissLinkWarning() {
linkWarning = null;
doSave();
}
async function toggle(tracker: any) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
@@ -114,22 +180,26 @@
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
ttFeedback = { ...ttFeedback, [key]: '' };
try {
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
ttFeedback = { ...ttFeedback, [key]: 'ok' };
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
ttFeedback = { ...ttFeedback, [key]: 'error' };
snackError(err.message);
} finally {
ttTesting = { ...ttTesting, [key]: '' };
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
}
}
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
}
// --- Linked Targets Management ---
function toggleExpand(trackerId: number) {
if (expandedTracker === trackerId) { expandedTracker = null; return; }
@@ -227,6 +297,9 @@
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
{#if col.updatedAt || col.updated_at}
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
{/if}
</label>
{/each}
</div>
@@ -243,7 +316,9 @@
</div>
</div>
<button type="submit" disabled={submitting} 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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
<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}Checking links...{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
</button>
</form>
</Card>
</div>
@@ -264,7 +339,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
@@ -295,7 +370,7 @@
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex items-center gap-2">
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
@@ -324,13 +399,6 @@
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
disabled={!!ttTesting[`${tt.id}_memory`]} />
{#each ['basic', 'periodic', 'memory'] as testType}
{#if ttFeedback[`${tt.id}_${testType}`]}
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
</span>
{/if}
{/each}
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
@@ -374,6 +442,48 @@
{/if}
{/if}
{#if linkWarning}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998; background:rgba(0,0,0,0.5);"
onclick={() => { linkWarning = null; }}
onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}>
</div>
<div style="position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999; width:28rem; max-width:90vw; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.5rem; box-shadow:0 20px 60px rgba(0,0,0,0.4);">
<div class="flex items-center gap-2 mb-3">
<span style="color: var(--color-warning-fg);"><MdiIcon name="mdiAlertCircle" size={22} /></span>
<h3 class="font-semibold">Albums Missing Public Links</h3>
</div>
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets.
</p>
<div class="space-y-1.5 mb-4 max-h-40 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)]'}">
{album.issue === 'expired' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> Public links allow anyone with the URL to view album contents. Albums without links will still be tracked and assets sent to chats, but messages won't include clickable links.
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={dismissLinkWarning}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
Save without links
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={autoCreateLinks} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? 'Creating...' : `Create ${linkWarning.albums.filter(a => a.issue === 'missing').length} link(s)`}
</button>
{/if}
</div>
</div>
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('trackers.confirmDelete')}