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}
|
||||
|
||||
|
||||
@@ -155,9 +155,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
{#if provider.icon}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
|
||||
{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon || 'mdiServer'} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{provider.name}</p>
|
||||
|
||||
@@ -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';
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
async function test(id: number) {
|
||||
try {
|
||||
const res = await api(`/targets/${id}/test`, { method: 'POST' });
|
||||
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
@@ -235,12 +235,16 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if target.icon}<MdiIcon name={target.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || (target.type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={20} /></span>
|
||||
<p class="font-medium">{target.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{target.type === 'telegram' ? `Chat: ${target.config?.chat_id || '***'}` : target.config?.url || ''}
|
||||
{#if target.type === 'telegram'}
|
||||
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
|
||||
{:else}
|
||||
{target.config?.url || ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -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';
|
||||
@@ -40,7 +40,7 @@
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name }) });
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} else {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
@@ -94,12 +94,27 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
let chatTesting = $state<Record<string, boolean>>({});
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
async function testChat(e: Event, botId: number, chatId: string) {
|
||||
e.stopPropagation();
|
||||
const key = `${botId}_${chatId}`;
|
||||
if (chatTesting[key]) return;
|
||||
chatTesting = { ...chatTesting, [key]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatTesting = { ...chatTesting, [key]: false };
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
@@ -161,7 +176,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
@@ -198,9 +213,14 @@
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags
|
||||
.replace(/<a href="([^"]*)">/g, '<a href="$1" target="_blank" rel="noopener">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/g, '</i>')
|
||||
.replace(/<code>/g, '<code>').replace(/<\/code>/g, '</code>')
|
||||
.replace(/<pre>/g, '<pre>').replace(/<\/pre>/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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user