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:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user