diff --git a/CLAUDE.md b/CLAUDE.md index 8eb5ee1..ee18c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,10 @@ PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | he PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/ ``` +## Test Credentials + +Default test account: username `admin`, password `admin1`. + ## Frontend Architecture Notes - **i18n**: Uses `$state` rune in `.svelte.ts` file. Locale auto-detects from localStorage. `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload. diff --git a/frontend/package.json b/frontend/package.json index 1c614d0..abce86c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,6 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", diff --git a/frontend/src/lib/components/JinjaEditor.svelte b/frontend/src/lib/components/JinjaEditor.svelte index a65eb05..33de030 100644 --- a/frontend/src/lib/components/JinjaEditor.svelte +++ b/frontend/src/lib/components/JinjaEditor.svelte @@ -67,7 +67,7 @@ }, }); - onMount(() => { + function buildExtensions(isDark: boolean) { const extensions = [ jinjaLang, errorLineField, @@ -88,17 +88,14 @@ '.ͼ5': { color: '#6b7280' }, }), ]; + if (isDark) extensions.push(oneDark); + if (placeholder) extensions.push(cmPlaceholder(placeholder)); + return extensions; + } - if (theme.isDark) { - extensions.push(oneDark); - } - - if (placeholder) { - extensions.push(cmPlaceholder(placeholder)); - } - + onMount(() => { view = new EditorView({ - state: EditorState.create({ doc: value, extensions }), + state: EditorState.create({ doc: value, extensions: buildExtensions(theme.isDark) }), parent: container, }); @@ -127,31 +124,8 @@ const currentDoc = view.state.doc.toString(); view.destroy(); - const extensions = [ - jinjaLang, - errorLineField, - EditorView.updateListener.of((update) => { - if (update.docChanged) { - onchange(update.state.doc.toString()); - } - }), - EditorView.lineWrapping, - EditorView.theme({ - '&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" }, - '.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' }, - '.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' }, - '.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' }, - '.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' }, - '.ͼc': { color: '#e879f9' }, - '.ͼd': { color: '#38bdf8' }, - '.ͼ5': { color: '#6b7280' }, - }), - ]; - if (isDark) extensions.push(oneDark); - if (placeholder) extensions.push(cmPlaceholder(placeholder)); - view = new EditorView({ - state: EditorState.create({ doc: currentDoc, extensions }), + state: EditorState.create({ doc: currentDoc, extensions: buildExtensions(isDark) }), parent: container, }); } diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 9bb422f..b91731d 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -12,6 +12,7 @@ "telegramBots": "Bots", "targets": "Targets", "users": "Users", + "settings": "Settings", "logout": "Logout" }, "auth": { @@ -62,6 +63,7 @@ "assets": "assets", "eventActivity": "Event Activity", "last14days": "Last 14 days", + "event": "event", "events": "events", "noChartData": "No event data yet" }, @@ -85,7 +87,11 @@ "checking": "Checking...", "loadError": "Failed to load providers.", "externalDomain": "External Domain", - "optional": "optional" + "optional": "optional", + "urlApiKeyRequired": "URL and API Key are required", + "externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.", + "testAndSave": "Test & Save", + "saveWithoutTest": "Save without testing" }, "trackers": { "title": "Trackers", @@ -134,7 +140,16 @@ "testBasic": "Send test message", "testPeriodic": "Test periodic summary", "testScheduled": "Test scheduled assets", - "testMemory": "Test memory / On This Day" + "testMemory": "Test memory / On This Day", + "checkingLinks": "Checking links...", + "missingLinksTitle": "Albums Missing Public Links", + "missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.", + "expired": "Expired", + "passwordProtected": "Password Protected", + "noLink": "No Link", + "saveWithoutLinks": "Save without links", + "createLinks": "Create {count} link(s)", + "linksNote": "You can also create links manually in Immich." }, "templates": { "title": "Templates", @@ -198,7 +213,8 @@ "create": "Create User", "delete": "Delete", "confirmDelete": "Delete this user?", - "joined": "joined" + "joined": "joined", + "noUsers": "No users found" }, "telegramBot": { "title": "Telegram Bots", @@ -220,21 +236,48 @@ "channel": "Channel", "confirmDelete": "Delete this bot?", "commands": "Commands", - "enabledCommands": "Enabled Commands", - "defaultCount": "Default result count", + "enabledCommands": "Enabled commands", + "defaultCount": "Default count", "responseMode": "Response mode", - "modeMedia": "Media (send photos)", - "modeText": "Text (send links)", + "modeMedia": "Media (photos)", + "modeText": "Text only", "botLocale": "Bot language", "rateLimits": "Rate Limits", "rateSearch": "Search cooldown", "rateFind": "Find cooldown", "rateDefault": "Default cooldown", - "syncCommands": "Sync to Telegram", + "syncCommands": "Sync with Telegram", "discoverChats": "Discover chats from Telegram", "clickToCopy": "Click to copy chat ID", "chatsDiscovered": "Chats discovered", - "chatDeleted": "Chat removed" + "chatDeleted": "Chat removed", + "cmdLocale": "Bot language", + "searchCooldown": "Search cooldown (s)", + "saveConfig": "Save config", + "commandsSynced": "Commands synced with Telegram", + "registerWebhook": "Register webhook", + "unregisterWebhook": "Unregister webhook", + "webhookRegistered": "Webhook registered", + "webhookUnregistered": "Webhook unregistered", + "updateMode": "Update mode", + "polling": "Polling", + "webhook": "Webhook", + "webhookStatus": "Webhook status", + "webhookActive": "Webhook active", + "webhookNotSet": "No webhook set", + "webhookVerified": "Webhook verified", + "webhookError": "Last error", + "pendingUpdates": "pending updates", + "pollingActive": "Polling active", + "telegramSettings": "Telegram Settings", + "externalUrl": "External URL", + "externalUrlHint": "Public URL of this Notify Bridge instance. Required for webhook mode.", + "webhookSecret": "Webhook secret", + "webhookSecretHint": "Optional secret token to verify webhook requests from Telegram", + "cacheTtl": "Media cache TTL (hours)", + "cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading (default: 48h)", + "settingsSaved": "Settings saved", + "noExternalDomain": "External domain URL not configured" }, "trackingConfig": { "title": "Tracking Configs", @@ -269,6 +312,9 @@ "assetType": "Asset type", "minRating": "Min rating", "memoryMode": "Memory Mode (On This Day)", + "memorySource": "Memory source", + "memorySourceAlbums": "Scan tracked albums", + "memorySourceNative": "Immich native memories", "test": "Test", "confirmDelete": "Delete this tracking config?", "sortNone": "None", @@ -282,7 +328,14 @@ "albumModeRandom": "Random", "assetTypeAll": "All", "assetTypePhoto": "Photo", - "assetTypeVideo": "Video" + "assetTypeVideo": "Video", + "periodic": "periodic", + "scheduled": "scheduled", + "memory": "memory", + "added": "added", + "removed": "removed", + "renamed": "renamed", + "deleted": "deleted" }, "templateConfig": { "title": "Template Configs", @@ -324,7 +377,8 @@ "variables": "Variables", "assetFields": "Asset fields (in {% for asset in added_assets %})", "albumFields": "Album fields (in {% for album in albums %})", - "confirmDelete": "Delete this template config?" + "confirmDelete": "Delete this template config?", + "invalidFormat": "Invalid format string" }, "templateVars": { "message_assets_added": { "description": "Notification when new assets are added to an album" }, @@ -378,10 +432,24 @@ "album_url_field": "Album share URL", "album_shared": "Whether album is shared" }, + "settings": { + "title": "Settings", + "description": "Global application settings", + "general": "General", + "externalUrl": "External URL", + "externalUrlHint": "Public URL of this Notify Bridge instance (e.g. https://notify.example.com)", + "telegram": "Telegram", + "webhookSecret": "Webhook Secret", + "webhookSecretHint": "Secret token to verify webhook requests from Telegram", + "cacheTtl": "Media Cache TTL (hours)", + "cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", + "saved": "Settings saved" + }, "hints": { "periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.", "scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.", "memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.", + "memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).", "favoritesOnly": "Only include assets marked as favorites.", "maxAssets": "Maximum number of asset details to include in a single notification message.", "periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.", @@ -406,6 +474,10 @@ "botLocale": "Language for command descriptions in Telegram's menu and bot response messages.", "rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit." }, + "snackbar": { + "showDetails": "Show details", + "hideDetails": "Hide details" + }, "snack": { "providerSaved": "Provider saved", "providerDeleted": "Provider deleted", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 9ebd439..d2de73a 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -12,6 +12,7 @@ "telegramBots": "Боты", "targets": "Получатели", "users": "Пользователи", + "settings": "Настройки", "logout": "Выход" }, "auth": { @@ -62,6 +63,7 @@ "assets": "файлов", "eventActivity": "Активность событий", "last14days": "Последние 14 дней", + "event": "событие", "events": "событий", "noChartData": "Нет данных о событиях" }, @@ -85,7 +87,11 @@ "checking": "Проверка...", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", - "optional": "необязательно" + "optional": "необязательно", + "urlApiKeyRequired": "URL и API ключ обязательны", + "externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.", + "testAndSave": "Проверить и сохранить", + "saveWithoutTest": "Сохранить без проверки" }, "trackers": { "title": "Трекеры", @@ -134,7 +140,16 @@ "testBasic": "Отправить тестовое сообщение", "testPeriodic": "Тест периодической сводки", "testScheduled": "Тест запланированных фото", - "testMemory": "Тест воспоминаний" + "testMemory": "Тест воспоминаний", + "checkingLinks": "Проверка ссылок...", + "missingLinksTitle": "Альбомы без публичных ссылок", + "missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.", + "expired": "Истёк", + "passwordProtected": "Защищён паролем", + "noLink": "Нет ссылки", + "saveWithoutLinks": "Сохранить без ссылок", + "createLinks": "Создать {count} ссылку(и)", + "linksNote": "Вы также можете создать ссылки вручную в Immich." }, "templates": { "title": "Шаблоны", @@ -198,7 +213,8 @@ "create": "Создать", "delete": "Удалить", "confirmDelete": "Удалить этого пользователя?", - "joined": "зарегистрирован" + "joined": "зарегистрирован", + "noUsers": "Пользователи не найдены" }, "telegramBot": { "title": "Telegram боты", @@ -221,10 +237,10 @@ "confirmDelete": "Удалить этого бота?", "commands": "Команды", "enabledCommands": "Включённые команды", - "defaultCount": "Кол-во результатов", + "defaultCount": "Кол-во по умолчанию", "responseMode": "Режим ответа", - "modeMedia": "Медиа (отправка фото)", - "modeText": "Текст (ссылки)", + "modeMedia": "Медиа (фото)", + "modeText": "Только текст", "botLocale": "Язык бота", "rateLimits": "Ограничения частоты", "rateSearch": "Кулдаун поиска", @@ -234,7 +250,34 @@ "discoverChats": "Обнаружить чаты из Telegram", "clickToCopy": "Нажмите, чтобы скопировать ID чата", "chatsDiscovered": "Чаты обнаружены", - "chatDeleted": "Чат удалён" + "chatDeleted": "Чат удалён", + "cmdLocale": "Язык бота", + "searchCooldown": "Кулдаун поиска (с)", + "saveConfig": "Сохранить настройки", + "commandsSynced": "Команды синхронизированы с Telegram", + "registerWebhook": "Зарегистрировать вебхук", + "unregisterWebhook": "Удалить вебхук", + "webhookRegistered": "Вебхук зарегистрирован", + "webhookUnregistered": "Вебхук удалён", + "updateMode": "Режим обновлений", + "polling": "Опрос", + "webhook": "Вебхук", + "webhookStatus": "Статус вебхука", + "webhookActive": "Вебхук активен", + "webhookNotSet": "Вебхук не установлен", + "webhookVerified": "Вебхук проверен", + "webhookError": "Последняя ошибка", + "pendingUpdates": "ожидающих обновлений", + "pollingActive": "Опрос активен", + "telegramSettings": "Настройки Telegram", + "externalUrl": "Внешний URL", + "externalUrlHint": "Публичный URL этого экземпляра Notify Bridge. Необходим для режима вебхука.", + "webhookSecret": "Секрет вебхука", + "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram (необязательно)", + "cacheTtl": "TTL кэша медиа (часы)", + "cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой (по умолчанию: 48ч)", + "settingsSaved": "Настройки сохранены", + "noExternalDomain": "Внешний URL домена не настроен" }, "trackingConfig": { "title": "Конфигурации отслеживания", @@ -269,6 +312,9 @@ "assetType": "Тип файлов", "minRating": "Мин. рейтинг", "memoryMode": "Воспоминания (В этот день)", + "memorySource": "Источник воспоминаний", + "memorySourceAlbums": "Сканировать альбомы", + "memorySourceNative": "Встроенные воспоминания Immich", "test": "Тест", "confirmDelete": "Удалить эту конфигурацию отслеживания?", "sortNone": "Нет", @@ -282,7 +328,14 @@ "albumModeRandom": "Случайный", "assetTypeAll": "Все", "assetTypePhoto": "Фото", - "assetTypeVideo": "Видео" + "assetTypeVideo": "Видео", + "periodic": "периодический", + "scheduled": "запланированный", + "memory": "воспоминания", + "added": "добавление", + "removed": "удаление", + "renamed": "переименование", + "deleted": "удалён" }, "templateConfig": { "title": "Конфигурации шаблонов", @@ -324,7 +377,8 @@ "variables": "Переменные", "assetFields": "Поля файла (в {% for asset in added_assets %})", "albumFields": "Поля альбома (в {% for album in albums %})", - "confirmDelete": "Удалить эту конфигурацию шаблона?" + "confirmDelete": "Удалить эту конфигурацию шаблона?", + "invalidFormat": "Некорректная строка формата" }, "templateVars": { "message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" }, @@ -378,10 +432,24 @@ "album_url_field": "Ссылка на альбом", "album_shared": "Общий альбом" }, + "settings": { + "title": "Настройки", + "description": "Глобальные настройки приложения", + "general": "Общие", + "externalUrl": "Внешний URL", + "externalUrlHint": "Публичный URL этого экземпляра Notify Bridge (напр. https://notify.example.com)", + "telegram": "Telegram", + "webhookSecret": "Секрет вебхука", + "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", + "cacheTtl": "TTL кэша медиа (часы)", + "cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", + "saved": "Настройки сохранены" + }, "hints": { "periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.", "scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.", "memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.", + "memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.", "periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", @@ -406,6 +474,10 @@ "botLocale": "Язык описаний команд в меню Telegram и ответов бота.", "rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений." }, + "snackbar": { + "showDetails": "Показать детали", + "hideDetails": "Скрыть детали" + }, "snack": { "providerSaved": "Провайдер сохранён", "providerDeleted": "Провайдер удалён", diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index 983436b..4414cc4 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -4,9 +4,9 @@
-

{page.status}

-

{page.error?.message || 'Page not found'}

- +

{page.status}

+

{page.error?.message || 'Page not found'}

+
Go home
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 396bced..3920bc3 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,12 +1,11 @@ + + + +{#if !loaded} + +{:else} +
+ + +

+ + {t('settings.general')} +

+
+
+ + +
+
+
+ + + +

+ + {t('settings.telegram')} +

+
+
+ + +
+
+ + +
+
+
+ + +
+{/if} diff --git a/frontend/src/routes/setup/+page.svelte b/frontend/src/routes/setup/+page.svelte index d754808..332a91e 100644 --- a/frontend/src/routes/setup/+page.svelte +++ b/frontend/src/routes/setup/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; import { setup } from '$lib/auth.svelte'; - import { t, initLocale } from '$lib/i18n'; + import { t } from '$lib/i18n'; import { initTheme } from '$lib/theme.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; @@ -13,7 +13,7 @@ let submitting = $state(false); let mounted = $state(false); - onMount(() => { initLocale(); initTheme(); mounted = true; }); + onMount(() => { initTheme(); mounted = true; }); async function handleSubmit(e: SubmitEvent) { e.preventDefault(); diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index aa6f493..3c72800 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -8,14 +8,16 @@ import Loading from '$lib/components/Loading.svelte'; import IconPicker from '$lib/components/IconPicker.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import Hint from '$lib/components/Hint.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import type { NotificationTarget, TelegramBot, TelegramChat } from '$lib/types'; - let targets = $state([]); - let bots = $state([]); - let botChats = $state>({}); + let targets = $state([]); + let bots = $state([]); + let botChats = $state>({}); let showForm = $state(false); let editing = $state(null); let formType = $state<'telegram' | 'webhook'>('telegram'); @@ -29,7 +31,7 @@ let submitting = $state(false); let loadError = $state(''); let showTelegramSettings = $state(false); - let confirmDelete = $state(null); + let confirmDelete = $state(null); onMount(load); async function load() { @@ -223,10 +225,7 @@ {#if targets.length === 0 && !showForm} -
-
-

{t('targets.noTargets')}

-
+
{:else}
diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte index 6b72f5e..82d9acc 100644 --- a/frontend/src/routes/telegram-bots/+page.svelte +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -8,11 +8,13 @@ import Loading from '$lib/components/Loading.svelte'; import IconPicker from '$lib/components/IconPicker.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte'; + import type { TelegramBot, TelegramChat } from '$lib/types'; - let bots = $state([]); + let bots = $state([]); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); @@ -21,15 +23,25 @@ let submitting = $state(false); let confirmDelete = $state(null); + // Global settings (loaded for webhook mode checks) + let settings = $state({}); + // Per-bot expandable sections - let chats = $state>({}); + let chats = $state>({}); let chatsLoading = $state>({}); let expandedSection = $state>({}); + // Webhook status per bot + let webhookStatus = $state>({}); + onMount(load); async function load() { - try { bots = await api('/telegram-bots'); } - catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } + try { + [bots, settings] = await Promise.all([ + api('/telegram-bots'), + api('/settings'), + ]); + } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; } } @@ -48,7 +60,7 @@ } form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await load(); } catch (err: any) { error = err.message; snackError(err.message); } - submitting = false; + finally { submitting = false; } } function remove(id: number) { @@ -96,6 +108,116 @@ let chatTesting = $state>({}); + // Commands config editing + let cmdConfig = $state>({}); + let cmdSaving = $state>({}); + let cmdSyncing = $state>({}); + let modeChanging = $state>({}); + + const allCommands = [ + { key: 'help', icon: 'mdiHelpCircle' }, + { key: 'status', icon: 'mdiChartBox' }, + { key: 'albums', icon: 'mdiImageMultiple' }, + { key: 'events', icon: 'mdiPulse' }, + { key: 'summary', icon: 'mdiFileDocumentEdit' }, + { key: 'latest', icon: 'mdiImagePlus' }, + { key: 'memory', icon: 'mdiHistory' }, + { key: 'random', icon: 'mdiDice3' }, + { key: 'search', icon: 'mdiMagnify' }, + { key: 'find', icon: 'mdiFileSearch' }, + { key: 'person', icon: 'mdiAccount' }, + { key: 'place', icon: 'mdiMapMarker' }, + { key: 'favorites', icon: 'mdiStar' }, + { key: 'people', icon: 'mdiAccountGroup' }, + ]; + + function initCmdConfig(bot: any) { + if (!cmdConfig[bot.id]) { + const cfg = bot.commands_config || {}; + cmdConfig = { ...cmdConfig, [bot.id]: { + enabled: cfg.enabled || ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'], + locale: cfg.locale || 'en', + response_mode: cfg.response_mode || 'media', + default_count: cfg.default_count || 5, + rate_limits: { search: cfg.rate_limits?.search || 30, default: cfg.rate_limits?.default || 10 }, + }}; + } + } + + function toggleCmd(botId: number, cmd: string) { + const cfg = cmdConfig[botId]; + if (!cfg) return; + const enabled = [...cfg.enabled]; + const idx = enabled.indexOf(cmd); + if (idx >= 0) enabled.splice(idx, 1); + else enabled.push(cmd); + cmdConfig = { ...cmdConfig, [botId]: { ...cfg, enabled } }; + } + + async function saveCmdConfig(botId: number) { + cmdSaving = { ...cmdSaving, [botId]: true }; + try { + await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ commands_config: cmdConfig[botId] }) }); + await load(); + snackSuccess(t('snack.botUpdated')); + } catch (err: any) { snackError(err.message); } + cmdSaving = { ...cmdSaving, [botId]: false }; + } + + async function syncCommands(botId: number) { + cmdSyncing = { ...cmdSyncing, [botId]: true }; + try { + const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' }); + if (res.success) snackSuccess(t('telegramBot.commandsSynced')); + else snackError(res.error || 'Failed'); + } catch (err: any) { snackError(err.message); } + cmdSyncing = { ...cmdSyncing, [botId]: false }; + } + + async function switchMode(botId: number, mode: string) { + modeChanging = { ...modeChanging, [botId]: true }; + try { + const res = await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) }); + await load(); + if (mode === 'webhook') { + // Load webhook status after switching + await loadWebhookStatus(botId); + } + snackSuccess(t('snack.botUpdated')); + } catch (err: any) { snackError(err.message); } + modeChanging = { ...modeChanging, [botId]: false }; + } + + async function loadWebhookStatus(botId: number) { + try { + webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) }; + } catch { webhookStatus = { ...webhookStatus, [botId]: null }; } + } + + async function registerWebhook(botId: number) { + modeChanging = { ...modeChanging, [botId]: true }; + try { + const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' }); + if (res.success) { + snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered')); + await loadWebhookStatus(botId); + } else { + snackError(res.error || 'Failed to register webhook'); + } + } catch (err: any) { snackError(err.message); } + modeChanging = { ...modeChanging, [botId]: false }; + } + + async function unregisterWebhook(botId: number) { + modeChanging = { ...modeChanging, [botId]: true }; + try { + const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' }); + if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); } + else snackError(res.error || 'Failed'); + } catch (err: any) { snackError(err.message); } + modeChanging = { ...modeChanging, [botId]: false }; + } + function copyChatId(e: Event, chatId: string) { e.stopPropagation(); navigator.clipboard.writeText(chatId); @@ -164,10 +286,7 @@ {#if bots.length === 0 && !showForm} -
-
-

{t('telegramBot.noBots')}

-
+
{:else}
@@ -181,6 +300,12 @@ {#if bot.bot_username} @{bot.bot_username} {/if} + + + {bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')} +

{bot.token_preview}

@@ -190,6 +315,10 @@ class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1"> {t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'} + remove(bot.id)} variant="danger" />
@@ -231,6 +360,135 @@ {/if} + + {#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]} +
+ +
+

{t('telegramBot.enabledCommands')}

+
+ {#each allCommands as cmd} + + {/each} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+

{t('telegramBot.updateMode')}

+
+
+ + +
+ + {#if bot.update_mode === 'polling'} + + + {t('telegramBot.pollingActive')} + + {/if} + + {#if bot.update_mode === 'webhook'} + + + + {#if webhookStatus[bot.id]} + {@const ws = webhookStatus[bot.id]} + + {ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')} + {#if ws.pending_update_count > 0} + ({ws.pending_update_count} {t('telegramBot.pendingUpdates')}) + {/if} + + {#if ws.last_error_message} + {t('telegramBot.webhookError')}: {ws.last_error_message} + {/if} + {:else} + + {/if} + {/if} + + {#if !settings.external_url && bot.update_mode === 'webhook'} + + + {t('telegramBot.noExternalDomain')} + + {/if} +
+
+
+ {/if} {/each} diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index b1ef7af..43b75a9 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -8,26 +8,47 @@ import Loading from '$lib/components/Loading.svelte'; import IconPicker from '$lib/components/IconPicker.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import Hint from '$lib/components/Hint.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import Modal from '$lib/components/Modal.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import type { TemplateConfig } from '$lib/types'; - let configs = $state([]); + let configs = $state([]); let loaded = $state(false); let varsRef = $state>({}); let showVarsFor = $state(null); let showForm = $state(false); let editing = $state(null); let error = $state(''); - let confirmDelete = $state(null); + let confirmDelete = $state<{ id: number; onconfirm: () => Promise } | null>(null); let slotPreview = $state>({}); let slotErrors = $state>({}); let slotErrorLines = $state>({}); let slotErrorTypes = $state>({}); let validateTimers: Record> = {}; + let dateFormatPreview = $state>({}); + + function refreshDateFormatPreview() { + clearTimeout(validateTimers['_dateFmt']); + validateTimers['_dateFmt'] = setTimeout(async () => { + try { + const res = await api('/template-configs/preview-date-format', { + method: 'POST', + body: JSON.stringify({ + date_format: (form as any).date_format, + date_only_format: (form as any).date_only_format, + }), + }); + dateFormatPreview = res; + } catch { + dateFormatPreview = {}; + } + }, 400); + } function validateSlot(slotKey: string, template: string, immediate = false) { if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]); @@ -71,6 +92,7 @@ } } } + refreshDateFormatPreview(); } const defaultForm = () => ({ @@ -119,10 +141,10 @@ finally { loaded = true; } } - function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; } + function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); } function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; - slotPreview = {}; slotErrors = {}; + slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; setTimeout(() => refreshAllPreviews(), 100); } @@ -154,8 +176,8 @@ .replace(/&/g, '&') .replace(//g, '>') - // Restore allowed tags - .replace(/<a href="([^"]*)">/g, '') + // Restore allowed tags — only http(s) URLs for to prevent javascript: XSS + .replace(/<a href="(https?:\/\/[^&]*)">/g, '') .replace(/<\/a>/g, '') .replace(/<b>/g, '').replace(/<\/b>/g, '') .replace(/<i>/g, '').replace(/<\/i>/g, '') @@ -229,8 +251,13 @@ {#if slot.key === 'date_format' || slot.key === 'date_only_format'} { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }} + oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" /> + {#if dateFormatPreview[slot.key]} +

{t('templateConfig.preview')}: {dateFormatPreview[slot.key]}

+ {:else if dateFormatPreview[slot.key] === null} +

{t('templateConfig.invalidFormat')}

+ {/if} {:else} { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} /> {#if slotErrors[slot.key]} @@ -262,10 +289,7 @@ {#if configs.length === 0 && !showForm} -
-
-

{t('templateConfig.noConfigs')}

-
+
{:else}
diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte index 2765156..656d5ba 100644 --- a/frontend/src/routes/trackers/+page.svelte +++ b/frontend/src/routes/trackers/+page.svelte @@ -8,24 +8,27 @@ import Loading from '$lib/components/Loading.svelte'; import IconPicker from '$lib/components/IconPicker.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; + import Modal from '$lib/components/Modal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import Hint from '$lib/components/Hint.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import type { Tracker, ServiceProvider, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types'; let loaded = $state(false); let loadError = $state(''); - let trackers = $state([]); - let providers = $state([]); - let targets = $state([]); - let trackingConfigs = $state([]); - let templateConfigs = $state([]); + let trackers = $state([]); + let providers = $state([]); + let targets = $state([]); + let trackingConfigs = $state([]); + let templateConfigs = $state([]); let collections = $state([]); let showForm = $state(false); let editing = $state(null); let collectionFilter = $state(''); let submitting = $state(false); - let confirmDelete = $state(null); + let confirmDelete = $state(null); let toggling = $state>({}); // Per tracker-target test state (keyed by `${ttId}_${testType}`) let ttTesting = $state>({}); @@ -153,9 +156,9 @@ await doSave(); } - function dismissLinkWarning() { + async function dismissLinkWarning() { linkWarning = null; - doSave(); + await doSave(); } async function toggle(tracker: any) { if (toggling[tracker.id]) return; @@ -334,7 +337,7 @@
@@ -344,10 +347,7 @@ {#if loaded && !loadError} {#if trackers.length === 0 && !showForm} -
-
-

{t('trackers.noTrackers')}

-
+
{:else if !showForm}
@@ -478,47 +478,38 @@
{/if} -{#if linkWarning} - -
{ linkWarning = null; }} - onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}> -
-
-
- -

Albums Missing Public Links

-
+ { linkWarning = null; }}> + {#if linkWarning}

- The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets. + {t('trackers.missingLinksDesc')}

{#each linkWarning.albums as album}
{album.name} - {album.issue === 'expired' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'} + {album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
{/each}

- 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. + {t('trackers.linksNote')}

{#if linkWarning.albums.some(a => a.issue === 'missing')} {/if}
-
-{/if} + {/if} + ([]); + let configs = $state([]); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); let error = $state(''); - let confirmDelete = $state(null); + let confirmDelete = $state<{ id: number; onconfirm: () => Promise } | null>(null); const defaultForm = () => ({ provider_type: 'immich', name: '', icon: '', @@ -31,7 +33,7 @@ scheduled_enabled: false, scheduled_times: '09:00', scheduled_collection_mode: 'per_collection', 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_collection_mode: 'combined', + memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined', memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0, }); let form = $state(defaultForm()); @@ -170,6 +172,10 @@ {#if form.memory_enabled}
+
+