feat: test menu dropdown, split text/media messages, target settings, provider URL links

- Replace 3 test buttons with unified dropdown menu (basic/periodic/scheduled/memory)
- Send text message first, then assets as reply (not combined caption+media)
- Pass all target config settings to Telegram client (disable_url_preview, max_media, chunk_delay, etc.)
- Real data test notifications for periodic/scheduled/memory (fetch from Immich)
- Provider card URL is now a clickable hyperlink
- Localized test type labels (EN/RU)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:34:25 +03:00
parent 03c5c66eed
commit 5015e378fe
9 changed files with 407 additions and 78 deletions
+10 -2
View File
@@ -59,7 +59,11 @@
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"asset": "asset",
"assets": "assets"
"assets": "assets",
"eventActivity": "Event Activity",
"last14days": "Last 14 days",
"events": "events",
"noChartData": "No event data yet"
},
"providers": {
"title": "Providers",
@@ -126,7 +130,11 @@
"batchDuration": "Batch duration (seconds)",
"linkedTargets": "targets",
"noLinkedTargets": "No targets linked. Add a target below.",
"addTarget": "Add target"
"addTarget": "Add target",
"testBasic": "Send test message",
"testPeriodic": "Test periodic summary",
"testScheduled": "Test scheduled assets",
"testMemory": "Test memory / On This Day"
},
"templates": {
"title": "Templates",
+10 -2
View File
@@ -59,7 +59,11 @@
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"asset": "файл",
"assets": "файлов"
"assets": "файлов",
"eventActivity": "Активность событий",
"last14days": "Последние 14 дней",
"events": "событий",
"noChartData": "Нет данных о событиях"
},
"providers": {
"title": "Провайдеры",
@@ -126,7 +130,11 @@
"batchDuration": "Длительность пакета (секунды)",
"linkedTargets": "получатели",
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
"addTarget": "Добавить получателя"
"addTarget": "Добавить получателя",
"testBasic": "Отправить тестовое сообщение",
"testPeriodic": "Тест периодической сводки",
"testScheduled": "Тест запланированных фото",
"testMemory": "Тест воспоминаний"
},
"templates": {
"title": "Шаблоны",
+10 -2
View File
@@ -6,9 +6,11 @@
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EventChart from '$lib/components/EventChart.svelte';
let status = $state<any>(null);
let providers = $state<any[]>([]);
let chartDays = $state<any[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -33,7 +35,7 @@
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 FIXED_OVERHEAD = 600; // header + stats + chart + events header + filters + paginator + padding
const available = window.innerHeight - FIXED_OVERHEAD;
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
}
@@ -108,10 +110,14 @@
async function loadInitial() {
try {
[status, providers] = await Promise.all([
const [statusRes, providersRes, chartRes] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<any[]>('/providers'),
api<any>('/status/chart'),
]);
status = statusRes;
providers = providersRes;
chartDays = chartRes.days || [];
setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
@@ -200,6 +206,8 @@
{/each}
</div>
<EventChart days={chartDays} />
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
+3 -1
View File
@@ -161,7 +161,9 @@
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config?.url || ''}</p>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
+47 -11
View File
@@ -176,13 +176,23 @@
} catch (err: any) { error = err.message; snackError(err.message); }
confirmDelete = null;
}
let testMenuOpen = $state<string | null>(null);
let testMenuStyle = $state('');
const testTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' },
];
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
testMenuOpen = null;
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
try {
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
snackError(err.message);
@@ -190,6 +200,13 @@
ttTesting = { ...ttTesting, [key]: '' };
}
}
function openTestMenu(ttId: number, event: MouseEvent) {
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
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 {
@@ -390,15 +407,11 @@
<option value={0}>— {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<IconButton icon="mdiSend" size={14} title={t('common.test')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'basic')}
disabled={!!ttTesting[`${tt.id}_basic`]} />
<IconButton icon="mdiCalendarClock" size={14} title={t('trackingConfig.periodicSummary')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'periodic')}
disabled={!!ttTesting[`${tt.id}_periodic`]} />
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
disabled={!!ttTesting[`${tt.id}_memory`]} />
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => openTestMenu(tt.id, e)}
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
</div>
<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)} />
@@ -442,6 +455,29 @@
{/if}
{/if}
{#if testMenuOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
onclick={() => testMenuOpen = null}
onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
{#each testTypes as tt}
{@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
<button
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{/each}
</div>
{/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);"