Add frontend for TrackingConfig + TemplateConfig, fix locale, simplify trackers
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
New pages: - /tracking-configs: Full CRUD with event tracking, asset display, periodic summary, scheduled assets, and memory mode sections. Collapsible sub-sections that show/hide based on enabled state. - /template-configs: Full CRUD with all 21 template slots organized into 5 fieldsets (event messages, asset formatting, date/location, scheduled messages, telegram). Preview support per slot. Updated pages: - Targets: added tracking_config_id + template_config_id selectors (dropdowns populated from configs). Configs are reusable. - Trackers: simplified to album selection + scan interval + targets. Added Test, Test Periodic, Test Memory buttons per tracker. - Nav: replaced Templates with Tracking + Templates config links Other fixes: - Language button: now triggers window.location.reload() to force all child pages to re-evaluate t() calls - Dark theme buttons: changed primary color to dark gray in dark mode - Removed old /templates page (replaced by /template-configs) - Added .gitignore for __pycache__ in server package Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,8 @@
|
||||
--color-muted: #27272a;
|
||||
--color-muted-foreground: #a1a1aa;
|
||||
--color-border: #3f3f46;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #18181b;
|
||||
--color-primary: #3f3f46;
|
||||
--color-primary-foreground: #fafafa;
|
||||
--color-accent: #27272a;
|
||||
--color-accent-foreground: #fafafa;
|
||||
--color-destructive: #f87171;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"trackers": "Trackers",
|
||||
"templates": "Templates",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"targets": "Targets",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
@@ -157,6 +158,75 @@
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Tracking Configs",
|
||||
"description": "Define what events and assets to react to",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default tracking",
|
||||
"noConfigs": "No tracking configs yet.",
|
||||
"eventTracking": "Event Tracking",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
"assetDisplay": "Asset Display",
|
||||
"includePeople": "Include people",
|
||||
"includeDetails": "Include asset details",
|
||||
"maxAssets": "Max assets to show",
|
||||
"sortBy": "Sort by",
|
||||
"sortOrder": "Sort order",
|
||||
"periodicSummary": "Periodic Summary",
|
||||
"enabled": "Enabled",
|
||||
"intervalDays": "Interval (days)",
|
||||
"startDate": "Start date",
|
||||
"times": "Times (HH:MM)",
|
||||
"scheduledAssets": "Scheduled Assets",
|
||||
"albumMode": "Album mode",
|
||||
"limit": "Limit",
|
||||
"assetType": "Asset type",
|
||||
"minRating": "Min rating",
|
||||
"memoryMode": "Memory Mode (On This Day)",
|
||||
"test": "Test"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default EN",
|
||||
"noConfigs": "No template configs yet.",
|
||||
"eventMessages": "Event Messages",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"assetFormatting": "Asset Formatting",
|
||||
"imageTemplate": "Image item",
|
||||
"videoTemplate": "Video item",
|
||||
"assetsWrapper": "Assets wrapper",
|
||||
"moreMessage": "More message",
|
||||
"peopleFormat": "People format",
|
||||
"dateLocation": "Date & Location",
|
||||
"dateFormat": "Date format",
|
||||
"commonDate": "Common date",
|
||||
"uniqueDate": "Per-asset date",
|
||||
"locationFormat": "Location format",
|
||||
"commonLocation": "Common location",
|
||||
"uniqueLocation": "Per-asset location",
|
||||
"favoriteIndicator": "Favorite indicator",
|
||||
"scheduledMessages": "Scheduled Messages",
|
||||
"periodicSummary": "Periodic summary",
|
||||
"periodicAlbum": "Per-album item",
|
||||
"scheduledAssets": "Scheduled assets",
|
||||
"memoryMode": "Memory mode",
|
||||
"telegramSettings": "Telegram",
|
||||
"videoWarning": "Video warning",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
@@ -171,6 +241,8 @@
|
||||
"theme": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"test": "Test",
|
||||
"create": "Create"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dashboard": "Главная",
|
||||
"servers": "Серверы",
|
||||
"trackers": "Трекеры",
|
||||
"templates": "Шаблоны",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"targets": "Получатели",
|
||||
"users": "Пользователи",
|
||||
"logout": "Выход"
|
||||
@@ -157,6 +158,75 @@
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Основное отслеживание",
|
||||
"noConfigs": "Конфигураций отслеживания пока нет.",
|
||||
"eventTracking": "Отслеживание событий",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
"assetDisplay": "Отображение файлов",
|
||||
"includePeople": "Включать людей",
|
||||
"includeDetails": "Включать детали",
|
||||
"maxAssets": "Макс. файлов",
|
||||
"sortBy": "Сортировка",
|
||||
"sortOrder": "Порядок",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"enabled": "Включено",
|
||||
"intervalDays": "Интервал (дни)",
|
||||
"startDate": "Дата начала",
|
||||
"times": "Время (ЧЧ:ММ)",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"albumMode": "Режим альбомов",
|
||||
"limit": "Лимит",
|
||||
"assetType": "Тип файлов",
|
||||
"minRating": "Мин. рейтинг",
|
||||
"memoryMode": "Воспоминания (В этот день)",
|
||||
"test": "Тест"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "По умолчанию RU",
|
||||
"noConfigs": "Конфигураций шаблонов пока нет.",
|
||||
"eventMessages": "Сообщения о событиях",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"assetFormatting": "Форматирование файлов",
|
||||
"imageTemplate": "Шаблон фото",
|
||||
"videoTemplate": "Шаблон видео",
|
||||
"assetsWrapper": "Обёртка списка",
|
||||
"moreMessage": "Сообщение \"ещё\"",
|
||||
"peopleFormat": "Формат людей",
|
||||
"dateLocation": "Дата и место",
|
||||
"dateFormat": "Формат даты",
|
||||
"commonDate": "Общая дата",
|
||||
"uniqueDate": "Дата файла",
|
||||
"locationFormat": "Формат места",
|
||||
"commonLocation": "Общее место",
|
||||
"uniqueLocation": "Место файла",
|
||||
"favoriteIndicator": "Индикатор избранного",
|
||||
"scheduledMessages": "Запланированные сообщения",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"periodicAlbum": "Элемент альбома",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"memoryMode": "Воспоминания",
|
||||
"telegramSettings": "Telegram",
|
||||
"videoWarning": "Предупреждение о видео",
|
||||
"preview": "Предпросмотр"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"save": "Сохранить",
|
||||
@@ -171,6 +241,8 @@
|
||||
"theme": "Тема",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"system": "Системная"
|
||||
"system": "Системная",
|
||||
"test": "Тест",
|
||||
"create": "Создать"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
{ href: '/', key: 'nav.dashboard', icon: '⊞' },
|
||||
{ href: '/servers', key: 'nav.servers', icon: '⬡' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: '◎' },
|
||||
{ href: '/templates', key: 'nav.templates', icon: '⎘' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: '◇' },
|
||||
];
|
||||
|
||||
@@ -54,7 +55,9 @@
|
||||
|
||||
function toggleLocale() {
|
||||
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
||||
localeVersion++; // trigger re-render
|
||||
localeVersion++;
|
||||
// Force full page re-render so child components re-evaluate t() calls
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
||||
<div class="flex justify-end gap-1 mb-4">
|
||||
<button onclick={() => setLocale(getLocale() === 'en' ? 'ru' : 'en')}
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); window.location.reload(); }}
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
|
||||
@@ -7,19 +7,28 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
const defaultForm = () => ({ name: '', bot_token: '', chat_id: '', url: '', headers: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false });
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false,
|
||||
tracking_config_id: 0, template_config_id: 0 });
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let testResult = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { targets = await api('/targets'); } catch {} finally { loaded = true; } }
|
||||
async function load() {
|
||||
try {
|
||||
[targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/targets'), api('/tracking-configs'), api('/template-configs')
|
||||
]);
|
||||
} catch {} finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
|
||||
function edit(tgt: any) {
|
||||
@@ -31,6 +40,8 @@
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false,
|
||||
tracking_config_id: tgt.tracking_config_id ?? 0,
|
||||
template_config_id: tgt.template_config_id ?? 0,
|
||||
};
|
||||
editing = tgt.id; showForm = true;
|
||||
}
|
||||
@@ -45,10 +56,12 @@
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions }
|
||||
: { url: form.url, headers: form.headers ? JSON.parse(form.headers) : {}, ai_captions: form.ai_captions };
|
||||
const trkId = form.tracking_config_id || null;
|
||||
const tplId = form.template_config_id || null;
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, config }) });
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, config, tracking_config_id: trkId, template_config_id: tplId }) });
|
||||
} else {
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config }) });
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config, tracking_config_id: trkId, template_config_id: tplId }) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
@@ -134,6 +147,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config assignments -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
|
||||
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— None —</option>
|
||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
|
||||
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— None (default) —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}</label>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('targets.create')}</button>
|
||||
|
||||
176
frontend/src/routes/template-configs/+page.svelte
Normal file
176
frontend/src/routes/template-configs/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let previewSlot = $state('message_assets_added');
|
||||
let previewResult = $state('');
|
||||
let previewId = $state<number | null>(null);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
message_assets_added: '📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}',
|
||||
message_assets_removed: '🗑️ {removed_count} photo(s) removed from album "{album_name}".',
|
||||
message_album_renamed: '✏️ Album "{old_name}" renamed to "{new_name}".',
|
||||
message_album_deleted: '🗑️ Album "{album_name}" was deleted.',
|
||||
message_asset_image: '\n • 🖼️ {filename}',
|
||||
message_asset_video: '\n • 🎬 {filename}',
|
||||
message_assets_format: '\nAssets:{assets}',
|
||||
message_assets_more: '\n • ...and {more_count} more',
|
||||
message_people_format: ' People: {people}.',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
common_date_template: ' from {date}',
|
||||
date_if_unique_template: ' ({date})',
|
||||
location_format: '{city}, {country}',
|
||||
common_location_template: ' in {location}',
|
||||
location_if_unique_template: ' 📍 {location}',
|
||||
favorite_indicator: '❤️',
|
||||
periodic_summary_message: '📋 Tracked Albums Summary ({album_count} albums):{albums}',
|
||||
periodic_album_template: '\n • {album_name}: {album_url}',
|
||||
scheduled_assets_message: '📸 Here are some photos from album "{album_name}":{assets}',
|
||||
memory_mode_message: '📅 On this day:{assets}',
|
||||
video_warning: '\n\n⚠️ Note: Videos may not be sent due to Telegram\'s 50 MB file size limit.',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
const templateSlots = [
|
||||
{ group: 'eventMessages', slots: [
|
||||
{ key: 'message_assets_added', label: 'assetsAdded' },
|
||||
{ key: 'message_assets_removed', label: 'assetsRemoved' },
|
||||
{ key: 'message_album_renamed', label: 'albumRenamed' },
|
||||
{ key: 'message_album_deleted', label: 'albumDeleted' },
|
||||
]},
|
||||
{ group: 'assetFormatting', slots: [
|
||||
{ key: 'message_asset_image', label: 'imageTemplate' },
|
||||
{ key: 'message_asset_video', label: 'videoTemplate' },
|
||||
{ key: 'message_assets_format', label: 'assetsWrapper' },
|
||||
{ key: 'message_assets_more', label: 'moreMessage' },
|
||||
{ key: 'message_people_format', label: 'peopleFormat' },
|
||||
]},
|
||||
{ group: 'dateLocation', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat' },
|
||||
{ key: 'common_date_template', label: 'commonDate' },
|
||||
{ key: 'date_if_unique_template', label: 'uniqueDate' },
|
||||
{ key: 'location_format', label: 'locationFormat' },
|
||||
{ key: 'common_location_template', label: 'commonLocation' },
|
||||
{ key: 'location_if_unique_template', label: 'uniqueLocation' },
|
||||
{ key: 'favorite_indicator', label: 'favoriteIndicator' },
|
||||
]},
|
||||
{ group: 'scheduledMessages', slots: [
|
||||
{ key: 'periodic_summary_message', label: 'periodicSummary' },
|
||||
{ key: 'periodic_album_template', label: 'periodicAlbum' },
|
||||
{ key: 'scheduled_assets_message', label: 'scheduledAssets' },
|
||||
{ key: 'memory_mode_message', label: 'memoryMode' },
|
||||
]},
|
||||
{ group: 'telegramSettings', slots: [
|
||||
{ key: 'video_warning', label: 'videoWarning' },
|
||||
]},
|
||||
];
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
}
|
||||
|
||||
async function preview(id: number, slot: string) {
|
||||
previewId = id; previewSlot = slot;
|
||||
try {
|
||||
const res = await api(`/template-configs/${id}/preview?slot=${slot}`, { method: 'POST' });
|
||||
previewResult = res.rendered;
|
||||
} catch (err: any) { previewResult = `Error: ${err.message}`; }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
{#each templateSlots as group}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}</legend>
|
||||
<div class="space-y-3 mt-2">
|
||||
{#each group.slots as slot}
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
|
||||
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/each}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('templateConfig.noConfigs')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<pre class="text-xs text-[var(--color-muted-foreground)] mt-1 whitespace-pre-wrap font-mono bg-[var(--color-muted)] rounded p-2">{config.message_assets_added?.slice(0, 120)}...</pre>
|
||||
{#if previewResult && previewId === config.id}
|
||||
<div class="mt-2 p-2 bg-[var(--color-success-bg)] rounded text-sm">
|
||||
<p class="text-xs font-medium mb-1">{previewSlot}:</p>
|
||||
<pre class="whitespace-pre-wrap">{previewResult}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 ml-4">
|
||||
<button onclick={() => preview(config.id, 'message_assets_added')} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.preview')}</button>
|
||||
<button onclick={() => edit(config)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={() => remove(config.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let templates = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ name: '', body: '{{ added_count }} new item(s) added to album "{{ album_name }}".', event_type: '' });
|
||||
let preview = $state('');
|
||||
let previewId = $state<number | null>(null);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { templates = await api('/templates'); } catch {} finally { loaded = true; } }
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) { await api(`/templates/${editing}`, { method: 'PUT', body: JSON.stringify(form) }); }
|
||||
else { await api('/templates', { method: 'POST', body: JSON.stringify(form) }); }
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
}
|
||||
async function doPreview(id: number) {
|
||||
previewId = id;
|
||||
try { const res = await api<{ rendered: string }>(`/templates/${id}/preview`, { method: 'POST' }); preview = res.rendered; }
|
||||
catch (err: any) { preview = `Error: ${err.message}`; }
|
||||
}
|
||||
function edit(tmpl: any) { form = { name: tmpl.name, body: tmpl.body, event_type: tmpl.event_type || '' }; editing = tmpl.id; showForm = true; preview = ''; }
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('templates.confirmDelete'))) return;
|
||||
try { await api(`/templates/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('templates.title')} description={t('templates.description')}>
|
||||
<button onclick={() => { showForm = !showForm; editing = null; form = { name: '', body: '{{ added_count }} new item(s) added to album "{{ album_name }}".', event_type: '' }; preview = ''; }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('templates.cancel') : t('templates.newTemplate')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="tmpl-name" class="block text-sm font-medium mb-1">{t('templates.name')}</label>
|
||||
<input id="tmpl-name" bind:value={form.name} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tmpl-event" class="block text-sm font-medium mb-1">{t('templates.eventType')}</label>
|
||||
<select id="tmpl-event" bind:value={form.event_type} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('templates.allEvents')}</option>
|
||||
<option value="assets_added">{t('templates.assetsAdded')}</option>
|
||||
<option value="assets_removed">{t('templates.assetsRemoved')}</option>
|
||||
<option value="album_renamed">{t('templates.albumRenamed')}</option>
|
||||
<option value="album_deleted">{t('templates.albumDeleted')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tmpl-body" class="block text-sm font-medium mb-1">{t('templates.body')}</label>
|
||||
<textarea id="tmpl-body" bind:value={form.body} rows={8}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono"></textarea>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
|
||||
{t('templates.variables')}: {'{{ album_name }}'}, {'{{ added_count }}'}, {'{{ removed_count }}'}, {'{{ people }}'}, {'{{ change_type }}'}, {'{{ album_url }}'}, {'{{ added_assets }}'}, {'{{ old_name }}'}, {'{{ new_name }}'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('templates.update') : t('templates.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if templates.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('templates.noTemplates')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each templates as tmpl}
|
||||
<Card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{tmpl.name}</p>
|
||||
{#if tmpl.event_type}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tmpl.event_type}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<pre class="text-xs text-[var(--color-muted-foreground)] mt-1 whitespace-pre-wrap font-mono bg-[var(--color-muted)] rounded p-2">{tmpl.body.slice(0, 200)}{tmpl.body.length > 200 ? '...' : ''}</pre>
|
||||
{#if preview && previewId === tmpl.id && !showForm}
|
||||
<div class="mt-2 p-2 bg-[var(--color-success-bg)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap">{preview}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 ml-4">
|
||||
<button onclick={() => doPreview(tmpl.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templates.preview')}</button>
|
||||
<button onclick={() => edit(tmpl)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templates.edit')}</button>
|
||||
<button onclick={() => remove(tmpl.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('templates.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
@@ -14,11 +14,8 @@
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
const defaultForm = () => ({
|
||||
name: '', server_id: 0, album_ids: [] as string[], event_types: ['assets_added'],
|
||||
name: '', server_id: 0, album_ids: [] as string[],
|
||||
target_ids: [] as number[], scan_interval: 60,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
include_people: true, include_asset_details: false,
|
||||
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
@@ -33,11 +30,7 @@
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
name: trk.name, server_id: trk.server_id, album_ids: [...trk.album_ids],
|
||||
event_types: [...trk.event_types], target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
|
||||
track_images: trk.track_images ?? true, track_videos: trk.track_videos ?? true,
|
||||
notify_favorites_only: trk.notify_favorites_only ?? false, include_people: trk.include_people ?? true,
|
||||
include_asset_details: trk.include_asset_details ?? false, max_assets_to_show: trk.max_assets_to_show ?? 5,
|
||||
assets_order_by: trk.assets_order_by ?? 'none', assets_order: trk.assets_order ?? 'descending',
|
||||
target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
|
||||
};
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.server_id) await loadAlbums();
|
||||
@@ -102,58 +95,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.eventTypes')}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ['assets_added', 'assets_removed', 'album_renamed', 'album_sharing_changed', 'changed'] as evt}
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={form.event_types.includes(evt)}
|
||||
onchange={() => form.event_types = form.event_types.includes(evt) ? form.event_types.filter(e => e !== evt) : [...form.event_types, evt]} />
|
||||
{evt}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asset filtering -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackers.trackImages')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackers.trackVideos')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackers.favoritesOnly')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_people} /> {t('trackers.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackers.includeAssetDetails')}</label>
|
||||
</div>
|
||||
|
||||
<!-- Sorting -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-sort" class="block text-sm font-medium mb-1">{t('trackers.sortBy')}</label>
|
||||
<select id="trk-sort" bind:value={form.assets_order_by} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="none">{t('trackers.sortNone')}</option>
|
||||
<option value="date">{t('trackers.sortDate')}</option>
|
||||
<option value="rating">{t('trackers.sortRating')}</option>
|
||||
<option value="name">{t('trackers.sortName')}</option>
|
||||
<option value="random">{t('trackers.sortRandom')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-order" class="block text-sm font-medium mb-1">{t('trackers.sortOrder')}</label>
|
||||
<select id="trk-order" bind:value={form.assets_order} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="descending">{t('trackers.descending')}</option>
|
||||
<option value="ascending">{t('trackers.ascending')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-max" class="block text-sm font-medium mb-1">{t('trackers.maxAssetsToShow')}</label>
|
||||
<input id="trk-max" type="number" bind:value={form.max_assets_to_show} min="0" max="50" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}</label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-32 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
{#if targets.length > 0}
|
||||
@@ -191,10 +135,13 @@
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.event_types.join(', ')}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} target(s)</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory</button>
|
||||
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
</button>
|
||||
|
||||
204
frontend/src/routes/tracking-configs/+page.svelte
Normal file
204
frontend/src/routes/tracking-configs/+page.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', track_assets_added: true, track_assets_removed: false,
|
||||
track_album_renamed: true, track_album_deleted: true,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
include_people: true, include_asset_details: false,
|
||||
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
||||
periodic_enabled: false, periodic_interval_days: 1, periodic_start_date: '2025-01-01', periodic_times: '12:00',
|
||||
scheduled_enabled: false, scheduled_times: '09:00', scheduled_album_mode: 'per_album',
|
||||
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_times: '09:00', memory_album_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { configs = await api('/tracking-configs'); } catch {} finally { loaded = true; } }
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
editing = c.id; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<!-- Event tracking -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_album_renamed} /> {t('trackingConfig.albumRenamed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_album_deleted} /> {t('trackingConfig.albumDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackingConfig.trackImages')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackingConfig.trackVideos')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Asset display -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.assetDisplay')}</legend>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_people} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}</label>
|
||||
<input id="tc-max" type="number" bind:value={form.max_assets_to_show} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="none">None</option><option value="date">Date</option><option value="rating">Rating</option><option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="descending">Desc</option><option value="ascending">Asc</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Periodic summary -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.periodic_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.periodic_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.intervalDays')}</label><input type="number" bind:value={form.periodic_interval_days} min="1" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.startDate')}</label><input type="date" bind:value={form.periodic_start_date} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.periodic_times} placeholder="12:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Scheduled assets -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.scheduledAssets')}</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.scheduled_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.scheduled_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Memory mode -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.memoryMode')}</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.memory_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('trackingConfig.noConfigs')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_album_renamed && 'renamed', config.track_album_deleted && 'deleted'].filter(Boolean).join(', ')}
|
||||
{config.periodic_enabled ? ' · periodic' : ''}
|
||||
{config.scheduled_enabled ? ' · scheduled' : ''}
|
||||
{config.memory_enabled ? ' · memory' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => edit(config)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={() => remove(config.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
1
packages/server/.gitignore
vendored
Normal file
1
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -109,6 +109,44 @@ async def trigger_tracker(
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-periodic")
|
||||
async def test_periodic(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary notification to all targets."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
from ..database.models import NotificationTarget
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "periodic_summary", "results": results}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-memory")
|
||||
async def test_memory(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification to all targets."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
from ..database.models import NotificationTarget
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "memory_mode", "results": results}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
|
||||
Reference in New Issue
Block a user