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
+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">