feat: telegram commands, app settings, bot polling, webhook handling, UI improvements

Adds telegram bot command system with 13 commands (search, latest, random, etc.),
webhook/polling handlers, rate limiting, app settings page, and various UI/UX
improvements across all entity pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:11:42 +03:00
parent 5015e378fe
commit 03ec9b3c86
64 changed files with 2585 additions and 648 deletions
-1
View File
@@ -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",
+8 -34
View File
@@ -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,
});
}
+83 -11
View File
@@ -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",
+81 -9
View File
@@ -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": "Провайдер удалён",
+3 -3
View File
@@ -4,9 +4,9 @@
<div class="min-h-screen flex items-center justify-center">
<div class="text-center animate-fade-slide-in">
<h1 class="text-6xl font-bold text-muted-foreground mb-4">{page.status}</h1>
<p class="text-lg text-muted-foreground mb-8">{page.error?.message || 'Page not found'}</p>
<a href="/" class="px-6 py-3 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
<h1 class="text-6xl font-bold text-[var(--color-muted-foreground)] mb-4">{page.status}</h1>
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || 'Page not found'}</p>
<a href="/" class="px-6 py-3 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
Go home
</a>
</div>
+7 -21
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { t, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -37,7 +36,7 @@
let collapsed = $state(false);
const navItems = [
const baseNavItems = [
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
@@ -46,20 +45,23 @@
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
];
const navItems = $derived(auth.isAdmin
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
: baseNavItems
);
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
onMount(async () => {
initLocale();
initTheme();
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
}
await loadUser();
if (!auth.user && !isAuthPage) {
goto('/login');
window.location.href = '/login';
}
});
@@ -139,22 +141,6 @@
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
</a>
{/each}
{#if auth.isAdmin}
<a
href="/users"
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
style="color: {isActive('/users') ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive('/users') ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive('/users') ? '500' : '400'};"
onmouseenter={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
onmouseleave={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
title={collapsed ? t('nav.users') : ''}
>
{#if isActive('/users')}
<div style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if}
<MdiIcon name="mdiAccountGroup" size={18} />
{#if !collapsed}<span class="truncate">{t('nav.users')}</span>{/if}
</a>
{/if}
</nav>
<!-- Footer -->
+5 -1
View File
@@ -105,7 +105,11 @@
eventsLimit = calcPageSize();
window.addEventListener('resize', onResize);
loadInitial();
return () => window.removeEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
clearTimeout(searchTimeout);
clearTimeout(resizeTimeout);
};
});
async function loadInitial() {
+1 -2
View File
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { login } from '$lib/auth.svelte';
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -15,7 +15,6 @@
let mounted = $state(false);
onMount(async () => {
initLocale();
initTheme();
mounted = true;
try {
+5 -6
View File
@@ -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 } from '$lib/stores/snackbar.svelte';
import type { ServiceProvider } from '$lib/types';
let providers = $state<any[]>([]);
let providers = $state<ServiceProvider[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
@@ -20,7 +22,7 @@
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<ServiceProvider | null>(null);
let health = $state<Record<number, boolean | null>>({});
@@ -143,10 +145,7 @@
{#if providers.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiServer" size={40} /></div>
<p class="text-sm">{t('providers.noProviders')}</p>
</div>
<EmptyState icon="mdiServer" message={t('providers.noProviders')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
@@ -17,7 +17,7 @@
let saving = $state(false);
async function testAndSave() {
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
testing = true; error = '';
let createdId: number | null = null;
try {
@@ -44,7 +44,7 @@
}
async function saveWithoutTest() {
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
saving = true; error = '';
try {
await api('/providers', {
@@ -86,7 +86,7 @@
<div>
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.externalDomainHint')}</p>
</div>
{/if}
@@ -97,11 +97,11 @@
<div class="flex gap-3 pt-2">
<button onclick={testAndSave} disabled={testing || saving}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{testing ? t('providers.connecting') : 'Test & Save'}
{testing ? t('providers.connecting') : t('providers.testAndSave')}
</button>
<button onclick={saveWithoutTest} disabled={testing || saving}
class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-foreground)] rounded-md text-sm font-medium hover:opacity-80 disabled:opacity-50">
{saving ? t('common.loading') : 'Save without testing'}
{saving ? t('common.loading') : t('providers.saveWithoutTest')}
</button>
<a href="/providers" class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-muted-foreground)] rounded-md text-sm font-medium hover:opacity-80">
{t('common.cancel')}
+83
View File
@@ -0,0 +1,83 @@
<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';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let loaded = $state(false);
let saving = $state(false);
let settings = $state({
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48',
});
onMount(async () => {
try {
settings = await api('/settings');
} catch (err: any) { snackError(err.message); }
finally { loaded = true; }
});
async function save() {
saving = true;
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
snackSuccess(t('settings.saved'));
} catch (err: any) { snackError(err.message); }
saving = false;
}
</script>
<PageHeader title={t('settings.title')} description={t('settings.description')} />
{#if !loaded}
<Loading />
{:else}
<div class="space-y-6">
<!-- General section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiCog" size={18} />
{t('settings.general')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
</div>
</Card>
<!-- Telegram section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiSend" size={18} />
{t('settings.telegram')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder="optional"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
</Card>
<button onclick={save} disabled={saving}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{saving ? t('common.loading') : t('common.save')}
</button>
</div>
{/if}
+2 -2
View File
@@ -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();
+7 -8
View File
@@ -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<any[]>([]);
let bots = $state<any[]>([]);
let botChats = $state<Record<number, any[]>>({});
let targets = $state<NotificationTarget[]>([]);
let bots = $state<TelegramBot[]>([]);
let botChats = $state<Record<number, TelegramChat[]>>({});
let showForm = $state(false);
let editing = $state<number | null>(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<any>(null);
let confirmDelete = $state<NotificationTarget | null>(null);
onMount(load);
async function load() {
@@ -223,10 +225,7 @@
{#if targets.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
<p class="text-sm">{t('targets.noTargets')}</p>
</div>
<EmptyState icon="mdiTarget" message={t('targets.noTargets')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
+267 -9
View File
@@ -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<any[]>([]);
let bots = $state<TelegramBot[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -21,15 +23,25 @@
let submitting = $state(false);
let confirmDelete = $state<any>(null);
// Global settings (loaded for webhook mode checks)
let settings = $state<any>({});
// Per-bot expandable sections
let chats = $state<Record<number, any[]>>({});
let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
let webhookStatus = $state<Record<number, any>>({});
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<Record<string, boolean>>({});
// Commands config editing
let cmdConfig = $state<Record<number, any>>({});
let cmdSaving = $state<Record<number, boolean>>({});
let cmdSyncing = $state<Record<number, boolean>>({});
let modeChanging = $state<Record<number, boolean>>({});
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}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
<p class="text-sm">{t('telegramBot.noBots')}</p>
</div>
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
@@ -181,6 +300,12 @@
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
? 'bg-blue-500/10 text-blue-500'
: 'bg-emerald-500/10 text-emerald-500'}">
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
@@ -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' ? '▲' : '▼'}
</button>
<button onclick={() => { initCmdConfig(bot); toggleSection(bot.id, 'commands'); }}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
</div>
</div>
@@ -231,6 +360,135 @@
</button>
</div>
{/if}
<!-- Commands section -->
{#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
<!-- Command toggles -->
<div>
<p class="text-xs font-medium mb-2">{t('telegramBot.enabledCommands')}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1">
{#each allCommands as cmd}
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={cmdConfig[bot.id].enabled.includes(cmd.key)}
onchange={() => toggleCmd(bot.id, cmd.key)} />
<MdiIcon name={cmd.icon} size={14} />
/{cmd.key}
</label>
{/each}
</div>
</div>
<!-- Settings -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="block text-xs mb-1">{t('telegramBot.responseMode')}</label>
<select bind:value={cmdConfig[bot.id].response_mode}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="media">{t('telegramBot.modeMedia')}</option>
<option value="text">{t('telegramBot.modeText')}</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.cmdLocale')}</label>
<select bind:value={cmdConfig[bot.id].locale}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.defaultCount')}</label>
<input type="number" bind:value={cmdConfig[bot.id].default_count} min="1" max="20"
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.searchCooldown')}</label>
<input type="number" bind:value={cmdConfig[bot.id].rate_limits.search} min="0" max="300"
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<!-- Row 1: Config actions -->
<div class="flex items-center gap-2 flex-wrap">
<button onclick={() => saveCmdConfig(bot.id)} disabled={cmdSaving[bot.id]}
class="px-3 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{cmdSaving[bot.id] ? t('common.loading') : t('telegramBot.saveConfig')}
</button>
<button onclick={() => syncCommands(bot.id)} disabled={cmdSyncing[bot.id]}
class="px-3 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
{cmdSyncing[bot.id] ? t('common.loading') : t('telegramBot.syncCommands')}
</button>
</div>
<!-- Row 2: Update mode -->
<div class="border-t border-[var(--color-border)] pt-3">
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<button onclick={() => switchMode(bot.id, 'polling')}
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.polling')}
</button>
<button onclick={() => switchMode(bot.id, 'webhook')}
disabled={modeChanging[bot.id] || bot.update_mode === 'webhook'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'webhook'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiWebhook" size={14} />
{t('telegramBot.webhook')}
</button>
</div>
{#if bot.update_mode === 'polling'}
<span class="text-xs text-emerald-500 flex items-center gap-1">
<MdiIcon name="mdiCheckCircle" size={14} />
{t('telegramBot.pollingActive')}
</span>
{/if}
{#if bot.update_mode === 'webhook'}
<button onclick={() => registerWebhook(bot.id)} disabled={modeChanging[bot.id]}
class="px-2 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
{t('telegramBot.registerWebhook')}
</button>
<button onclick={() => unregisterWebhook(bot.id)} disabled={modeChanging[bot.id]}
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{t('telegramBot.unregisterWebhook')}
</button>
<!-- Webhook status -->
{#if webhookStatus[bot.id]}
{@const ws = webhookStatus[bot.id]}
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">
{ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')}
{#if ws.pending_update_count > 0}
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
{/if}
</span>
{#if ws.last_error_message}
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
{/if}
{:else}
<button onclick={() => loadWebhookStatus(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline">
{t('telegramBot.webhookStatus')}
</button>
{/if}
{/if}
{#if !settings.external_url && bot.update_mode === 'webhook'}
<span class="text-xs text-amber-500 flex items-center gap-1">
<MdiIcon name="mdiAlert" size={14} />
{t('telegramBot.noExternalDomain')}
</span>
{/if}
</div>
</div>
</div>
{/if}
</Card>
{/each}
</div>
@@ -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<any[]>([]);
let configs = $state<TemplateConfig[]>([]);
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let dateFormatPreview = $state<Record<string, string | null>>({});
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Restore allowed tags
.replace(/&lt;a href="([^"]*)"&gt;/g, '<a href="$1" target="_blank" rel="noopener">')
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
.replace(/&lt;a href=&quot;(https?:\/\/[^&]*)&quot;&gt;/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
.replace(/&lt;\/a&gt;/g, '</a>')
.replace(/&lt;b&gt;/g, '<b>').replace(/&lt;\/b&gt;/g, '</b>')
.replace(/&lt;i&gt;/g, '<i>').replace(/&lt;\/i&gt;/g, '</i>')
@@ -229,8 +251,13 @@
</div>
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
<input bind:value={(form as any)[slot.key]}
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }}
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]}
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
{:else if dateFormatPreview[slot.key] === null}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
{:else}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
@@ -262,10 +289,7 @@
{#if configs.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
</div>
<EmptyState icon="mdiFileDocumentEdit" message={t('templateConfig.noConfigs')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
+22 -31
View File
@@ -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<any[]>([]);
let providers = $state<any[]>([]);
let targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]);
let templateConfigs = $state<any[]>([]);
let trackers = $state<Tracker[]>([]);
let providers = $state<ServiceProvider[]>([]);
let targets = $state<NotificationTarget[]>([]);
let trackingConfigs = $state<TrackingConfig[]>([]);
let templateConfigs = $state<TemplateConfig[]>([]);
let collections = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<Tracker | null>(null);
let toggling = $state<Record<number, boolean>>({});
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
let ttTesting = $state<Record<string, string>>({});
@@ -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 @@
</div>
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}Checking links...{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
{#if linkCheckLoading}{t('trackers.checkingLinks')}{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
</button>
</form>
</Card>
@@ -344,10 +347,7 @@
{#if loaded && !loadError}
{#if trackers.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
<p class="text-sm">{t('trackers.noTrackers')}</p>
</div>
<EmptyState icon="mdiRadar" message={t('trackers.noTrackers')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
@@ -478,47 +478,38 @@
</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);"
onclick={() => { linkWarning = null; }}
onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}>
</div>
<div style="position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999; width:28rem; max-width:90vw; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.5rem; box-shadow:0 20px 60px rgba(0,0,0,0.4);">
<div class="flex items-center gap-2 mb-3">
<span style="color: var(--color-warning-fg);"><MdiIcon name="mdiAlertCircle" size={22} /></span>
<h3 class="font-semibold">Albums Missing Public Links</h3>
</div>
<Modal open={linkWarning !== null} title={t('trackers.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
{#if linkWarning}
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets.
{t('trackers.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'}
{album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> Public links allow anyone with the URL to view album contents. Albums without links will still be tracked and assets sent to chats, but messages won't include clickable links.
<MdiIcon name="mdiInformation" size={14} /> {t('trackers.linksNote')}
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={dismissLinkWarning}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
Save without links
{t('trackers.saveWithoutLinks')}
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={autoCreateLinks} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? 'Creating...' : `Create ${linkWarning.albums.filter(a => a.issue === 'missing').length} link(s)`}
{linkCreating ? t('common.loading') : t('trackers.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
</button>
{/if}
</div>
</div>
{/if}
{/if}
</Modal>
<ConfirmModal
open={!!confirmDelete}
@@ -8,17 +8,19 @@
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 { TrackingConfig } from '$lib/types';
let configs = $state<any[]>([]);
let configs = $state<TrackingConfig[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | 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 @@
<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.memorySource')}<Hint text={t('hints.memorySource')} /></label>
<select bind:value={form.memory_source} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="albums">{t('trackingConfig.memorySourceAlbums')}</option><option value="native">{t('trackingConfig.memorySourceNative')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
<select bind:value={form.memory_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
@@ -196,10 +202,7 @@
{#if configs.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
<p class="text-sm">{t('trackingConfig.noConfigs')}</p>
</div>
<EmptyState icon="mdiCog" message={t('trackingConfig.noConfigs')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
@@ -212,10 +215,10 @@
<p class="font-medium">{config.name}</p>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_collection_renamed && 'renamed', config.track_collection_deleted && 'deleted'].filter(Boolean).join(', ')}
{config.periodic_enabled ? ' · periodic' : ''}
{config.scheduled_enabled ? ' · scheduled' : ''}
{config.memory_enabled ? ' · memory' : ''}
{[config.track_assets_added && t('trackingConfig.added'), config.track_assets_removed && t('trackingConfig.removed'), config.track_collection_renamed && t('trackingConfig.renamed'), config.track_collection_deleted && t('trackingConfig.deleted')].filter(Boolean).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<div class="flex items-center gap-1">
+5 -6
View File
@@ -9,16 +9,18 @@
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<any[]>([]);
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
@@ -99,10 +101,7 @@
{#if users.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
<p class="text-sm">{t('common.loadError')}</p>
</div>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">