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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Провайдер удалён",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags
|
||||
.replace(/<a href="([^"]*)">/g, '<a href="$1" target="_blank" rel="noopener">')
|
||||
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
|
||||
.replace(/<a href="(https?:\/\/[^&]*)">/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user