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
+166 -5
View File
@@ -8,6 +8,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state<any>(null);
let providers = $state<any[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -16,6 +17,27 @@
let displayTotal = $state(0);
let displayTargets = $state(0);
// Event filters
let filterEventType = $state('');
let filterProviderId = $state('');
let filterSearch = $state('');
let filterSort = $state('newest');
let eventsLimit = $state(calcPageSize());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
/** Calculate how many event rows fit in the remaining viewport space. */
function calcPageSize(): number {
if (typeof window === 'undefined') return 8;
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
const FIXED_OVERHEAD = 390; // header + stats + events header + filters + paginator + padding
const available = window.innerHeight - FIXED_OVERHEAD;
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
}
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
if (to === 0) { setter(0); return; }
const start = performance.now();
@@ -29,9 +51,67 @@
requestAnimationFrame(frame);
}
onMount(async () => {
async function loadEvents() {
eventsLoading = true;
try {
status = await api<any>('/status');
const params = new URLSearchParams();
if (filterEventType) params.set('event_type', filterEventType);
if (filterProviderId) params.set('provider_id', filterProviderId);
if (filterSearch) params.set('search', filterSearch);
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
eventsLoading = false;
}
}
function applyFilters() {
eventsOffset = 0;
loadEvents();
}
function goToPage(page: number) {
eventsOffset = (page - 1) * eventsLimit;
loadEvents();
}
let searchTimeout: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
}
let resizeTimeout: ReturnType<typeof setTimeout>;
function onResize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newLimit = calcPageSize();
if (newLimit !== eventsLimit) {
eventsLimit = newLimit;
eventsOffset = 0;
loadEvents();
}
}, 200);
}
onMount(() => {
eventsLimit = calcPageSize();
window.addEventListener('resize', onResize);
loadInitial();
return () => window.removeEventListener('resize', onResize);
});
async function loadInitial() {
try {
[status, providers] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<any[]>('/providers'),
]);
setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
@@ -43,7 +123,7 @@
} finally {
loaded = true;
}
});
}
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
@@ -77,6 +157,15 @@
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
};
const eventTypeOptions = $derived([
{ value: '', label: t('dashboard.allEvents') },
{ value: 'assets_added', label: t('dashboard.filterAssetsAdded') },
{ value: 'assets_removed', label: t('dashboard.filterAssetsRemoved') },
{ value: 'collection_renamed', label: t('dashboard.filterRenamed') },
{ value: 'collection_deleted', label: t('dashboard.filterDeleted') },
{ value: 'sharing_changed', label: t('dashboard.filterSharingChanged') },
]);
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
@@ -114,8 +203,41 @@
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
{/if}
</h3>
{#if status.recent_events.length === 0}
<!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="flex-1 min-w-[150px] max-w-[260px]">
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
placeholder={t('dashboard.searchEvents')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<select bind:value={filterEventType} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
{#each eventTypeOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<select bind:value={filterProviderId} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="">{t('dashboard.allProviders')}</option>
{#each providers as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<select bind:value={filterSort} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="newest">{t('dashboard.newestFirst')}</option>
<option value="oldest">{t('dashboard.oldestFirst')}</option>
</select>
</div>
{#if eventsLoading}
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
{:else if status.recent_events.length === 0}
<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="mdiCalendarBlank" size={40} /></div>
@@ -130,19 +252,58 @@
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
<div class="event-content">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-2 min-w-0 flex-wrap">
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
</span>
<span class="text-sm font-medium truncate">{event.collection_name}</span>
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
{#if event.assets_count > 0}
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
{/if}
</div>
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
</div>
{#if event.provider_name || event.tracker_name}
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
{#if event.provider_name}
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
{/if}
{#if event.tracker_name}
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Paginator -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-1 mt-4">
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronLeft" size={16} />
</button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button onclick={() => goToPage(page)}
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
{/each}
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronRight" size={16} />
</button>
</div>
{/if}
{/if}
{/if}