feat: comprehensive code review fixes — security, performance, quality

Backend security:
- Reject Gitea webhooks when webhook_secret is empty (was silently skipping)
- Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints
- Add CORS middleware with configurable origins
- Mask telegram_webhook_secret in settings API response
- Protect system-owned command template configs from regular user modification
- Increase minimum password length to 8 characters

Backend performance:
- Batch queries in _resolve_command_context (3 queries instead of 3N)
- Concurrent album fetching with asyncio.gather in immich commands
- Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation)
- TTLCache for rate limits (bounded memory, auto-eviction)
- Optional aiohttp session reuse in send_reply/send_media_group

Backend code quality:
- Extract dispatch_helpers.py (shared link_data loading + event filtering)
- Extract database/seeds.py from main.py (490 lines → dedicated module)
- Split immich_handler.py (415 lines) into commands/immich/ subpackage
- Replace bare except blocks with logged warnings
- Add per-provider config validation (Pydantic models)
- Truncate command input to 512 chars
- Expose usage_* and desc_* slots in capabilities and variables API

Frontend security:
- CSS.escape() for user-controlled querySelector in highlight.ts
- Client-side password min 8 chars validation on setup and password change

Frontend code quality:
- Replace any types with proper interfaces across top files
- Decompose targets/+page.svelte into TargetForm + ReceiverSection
- Fix $derived.by usage, $state mutation patterns
- Add console.warn to empty catch blocks

Frontend UX:
- Auth redirect via goto() with "Redirecting..." state
- Platform-aware Ctrl/Cmd K keyboard hint
- Remove stat-card hover transform

Frontend accessibility:
- Modal: role=dialog, aria-modal, focus trap, restore focus
- EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
This commit is contained in:
2026-03-23 01:59:51 +03:00
parent 31584c5d31
commit e0bae394ee
78 changed files with 2855 additions and 1658 deletions
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -19,6 +20,7 @@
const theme = getTheme();
let showPasswordForm = $state(false);
let redirecting = $state(false);
let openSearch: (() => void) | undefined;
let pwdCurrent = $state('');
let pwdNew = $state('');
@@ -27,6 +29,7 @@
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.changePassword');
@@ -38,6 +41,7 @@
}
let collapsed = $state(false);
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
// Nav counts for badges
let navCounts = $state<Record<string, number>>({});
@@ -153,15 +157,16 @@
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch {}
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
await loadUser();
if (!auth.user && !isAuthPage) {
window.location.href = '/login';
redirecting = true;
goto('/login');
}
// Load nav counts
if (auth.user) {
try { navCounts = await api('/status/counts'); } catch {}
try { navCounts = await api('/status/counts'); } catch (e) { console.warn('Failed to load nav counts:', e); }
}
});
@@ -270,7 +275,7 @@
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
<span class="flex-1 text-left text-xs">{t('searchPalette.placeholder')}</span>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">K</kbd>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">{isMac ? '⌘' : 'Ctrl '}K</kbd>
{/if}
</button>
</div>
@@ -420,7 +425,7 @@
<div class="min-h-screen flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{redirecting ? t('common.redirecting') : t('common.loading')}</p>
</div>
</div>
{/if}
+10 -8
View File
@@ -12,13 +12,14 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { eventTypeFilterItems, sortFilterItems } from '$lib/grid-items';
let status = $state<any>(null);
import type { DashboardStatus } from '$lib/types';
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })),
]);
let chartDays = $state<any[]>([]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -78,7 +79,7 @@
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
@@ -90,9 +91,9 @@
try {
const params = buildFilterParams();
const qs = params.toString();
const chartRes = await api<any>(`/status/chart${qs ? '?' + qs : ''}`);
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
chartDays = chartRes.days || [];
} catch {}
} catch (e) { console.warn('Failed to load chart data:', e); }
}
// Auto-apply when filter values change (via IconGridSelect bind:value)
@@ -149,13 +150,14 @@
async function loadInitial() {
try {
const [statusRes, , chartRes] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<any>('/status/chart'),
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
]);
status = statusRes;
chartDays = chartRes.days || [];
setTimeout(() => {
if (!status) return;
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
@@ -339,7 +341,7 @@
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); transform: translateY(-2px); }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
+1 -1
View File
@@ -18,7 +18,7 @@
let error = $state('');
// Global settings (loaded for webhook mode checks)
let settings = $state<any>({});
let settings = $state<Record<string, string>>({});
onMount(load);
async function load() {
+1 -1
View File
@@ -19,7 +19,7 @@
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
let confirmDeleteEmail = $state<EmailBot | null>(null);
let error = $state('');
const defaultEmailForm = () => ({
+1 -1
View File
@@ -19,7 +19,7 @@
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<any>(null);
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
let error = $state('');
const defaultMatrixForm = () => ({
+27 -22
View File
@@ -11,9 +11,14 @@
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 { TelegramChat } from '$lib/types';
import type { TelegramBot, TelegramChat } from '$lib/types';
let { settings, onreload }: { settings: any; onreload: () => Promise<void> } = $props();
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
interface ListenerEntry { listener_type: string; listener_id: number }
interface WebhookStatusInfo { url?: string; pending_update_count?: number; last_error_message?: string }
interface ApiResult { success: boolean; error?: string; verified?: boolean }
let { settings, onreload }: { settings: Record<string, string>; onreload: () => Promise<void> } = $props();
let bots = $derived(telegramBotsCache.items);
let showForm = $state(false);
@@ -21,7 +26,7 @@
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
@@ -29,17 +34,17 @@
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
let webhookStatus = $state<Record<number, any>>({});
let webhookStatus = $state<Record<number, WebhookStatusInfo>>({});
let chatTesting = $state<Record<string, boolean>>({});
let modeChanging = $state<Record<number, boolean>>({});
// Listener status: command trackers using this bot
let botListenerStatus = $state<Record<number, any[]>>({});
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: any) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -78,14 +83,14 @@
async function loadChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
try { chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`) }; } catch (e) { console.warn('Failed to load chats:', e); chats = { ...chats, [botId]: [] }; }
chatsLoading = { ...chatsLoading, [botId]: false };
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try {
chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
@@ -94,7 +99,7 @@
async function deleteChat(botId: number, chatDbId: number) {
try {
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId);
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
}
@@ -102,24 +107,24 @@
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
const trackers = await api('/command-trackers');
const matched: any[] = [];
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
try {
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
if (hasBot) matched.push(trk);
} catch { /* ignore */ }
} catch (e) { console.warn('Failed to load listeners for tracker:', e); }
}
botListenerStatus = { ...botListenerStatus, [botId]: matched };
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
} catch (e) { console.warn('Failed to load listener status:', e); botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
botListenerLoading = { ...botListenerLoading, [botId]: false };
}
async function syncCommands(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
const res = await api<ApiResult>(`/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); }
@@ -141,14 +146,14 @@
async function loadWebhookStatus(botId: number) {
try {
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
webhookStatus = { ...webhookStatus, [botId]: await api<WebhookStatusInfo>(`/telegram-bots/${botId}/webhook/status`) };
} catch (e) { console.warn('Failed to load webhook status:', e); webhookStatus = { ...webhookStatus, [botId]: {} }; }
}
async function registerWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
if (res.success) {
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
await loadWebhookStatus(botId);
@@ -162,7 +167,7 @@
async function unregisterWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
const res = await api<ApiResult>(`/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); }
@@ -193,7 +198,7 @@
if (chatTesting[key]) return;
chatTesting = { ...chatTesting, [key]: true };
try {
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
@@ -398,7 +403,7 @@
{@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}
{#if (ws.pending_update_count ?? 0) > 0}
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
{/if}
</span>
@@ -17,10 +17,11 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
if (!id) return '';
const tpl = cmdTemplateConfigs.find((c: any) => c.id === id);
const tpl = cmdTemplateConfigs.find((c) => c.id === id);
return tpl?.name || `#${id}`;
}
@@ -33,15 +34,15 @@
));
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
const templateItems = $derived(cmdTemplateConfigs
.filter((c: any) => c.provider_type === form.provider_type)
.map((c: any) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
.filter((c) => c.provider_type === form.provider_type)
.map((c) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Immich command icons — used as fallback when capabilities don't specify icons
const commandIcons: Record<string, string> = {
@@ -54,7 +55,7 @@
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: any) => ({
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
@@ -88,12 +89,12 @@
function openNew() {
form = defaultForm();
// Auto-select first matching template for the default provider_type
const match = cmdTemplateConfigs.find((c: any) => c.provider_type === form.provider_type);
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
editing = null;
showForm = true;
}
function editConfig(cfg: any) {
function editConfig(cfg: CommandConfig) {
form = {
name: cfg.name,
icon: cfg.icon || '',
@@ -132,7 +133,7 @@
finally { submitting = false; }
}
function remove(cfg: any) {
function remove(cfg: CommandConfig) {
confirmDelete = {
id: cfg.id,
onconfirm: async () => {
@@ -11,9 +11,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { Tracker, TrackingConfig, TemplateConfig } from '$lib/types';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
@@ -34,7 +35,7 @@
let targets = $derived(targetsCache.items);
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
let collections = $state<any[]>([]);
let collections = $state<Record<string, any>[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
@@ -44,7 +45,7 @@
let ttTesting = $state<Record<string, string>>({});
// Shared link validation
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
let linkWarning = $state<{ albums: { id: string; name: string; issue: string }[], providerId: number } | null>(null);
let linkCheckLoading = $state(false);
let linkCreating = $state(false);
let previousCollectionIds = $state<string[]>([]);
@@ -83,7 +84,7 @@
];
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived(() => {
let testTypes = $derived.by(() => {
if (!testMenuTrackerId) return defaultTestTypes;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return defaultTestTypes;
@@ -98,7 +99,7 @@
loadError = '';
try {
[allNotificationTrackers] = await Promise.all([
api('/notification-trackers'),
api<Tracker[]>('/notification-trackers'),
providersCache.fetch(), targetsCache.fetch(),
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
]);
@@ -110,7 +111,7 @@
async function loadCollections() {
if (!form.provider_id) return;
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
}
let _prevProviderId = 0;
@@ -123,7 +124,7 @@
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
async function edit(trk: Tracker) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
@@ -143,13 +144,14 @@
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: any[] = [];
const missingAlbums: { id: string; name: string; issue: string }[] = [];
for (const albumId of newAlbumIds) {
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const links = await api<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
const problematicLink = links.find((l) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
@@ -164,7 +166,7 @@
linkCheckLoading = false;
return;
}
} catch { /* Proceed if check fails */ }
} catch (e) { console.warn('Shared link check failed, proceeding:', e); }
linkCheckLoading = false;
}
@@ -210,7 +212,7 @@
await doSave();
}
async function toggle(tracker: any) {
async function toggle(tracker: Tracker) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
try {
@@ -220,7 +222,7 @@
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: any) { confirmDelete = tracker; }
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
@@ -243,7 +245,7 @@
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
} catch (e) { console.warn('Date format error:', e); return ''; }
}
// --- Linked Targets helpers ---
@@ -252,7 +254,7 @@
expandedTracker = trackerId;
}
function getProviderType(tracker: any): string {
function getProviderType(tracker: Tracker): string {
const p = providers.find(p => p.id === tracker.provider_id);
return p?.type || '';
}
@@ -262,13 +264,13 @@
return p?.name || `#${id}`;
}
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
}
function getUnlinkedTargets(tracker: any): any[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
function getUnlinkedTargets(tracker: Tracker): NotificationTarget[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: TrackerTarget) => tt.target_id));
return targets.filter(t => !linkedIds.has(t.id));
}
@@ -302,7 +304,7 @@
} catch (err: any) { snackError(err.message); }
}
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
try {
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
method: 'PUT',
@@ -331,12 +333,12 @@
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id ?? null;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id ?? null;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
function handleTestFromMenu(ttId: number, testType: string) {
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id;
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id;
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
}
</script>
@@ -451,7 +453,7 @@
{testMenuOpen}
{testMenuStyle}
{ttTesting}
testTypes={testTypes()}
testTypes={testTypes}
ontest={handleTestFromMenu}
onclose={() => testMenuOpen = null}
/>
+1 -1
View File
@@ -20,7 +20,7 @@
e.preventDefault();
error = '';
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
if (password.length < 8) { error = t('auth.passwordTooShort'); return; }
submitting = true;
try {
await setup(username, password);
+54 -224
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
@@ -8,23 +7,22 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
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 CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
// ── Helpers ──
function getBotName(target: any): string | null {
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
const bot = telegramBots.find(b => b.id === target.config.bot_id);
return bot?.name || null;
@@ -40,14 +38,14 @@
return null;
}
function getBotHref(target: any): string {
function getBotHref(target: NotificationTarget): string {
if (target.type === 'telegram') return '/bots?tab=telegram';
if (target.type === 'email') return '/bots?tab=email';
if (target.type === 'matrix') return '/bots?tab=matrix';
return '/bots?tab=telegram';
}
function getBotEntityId(target: any): number | null {
function getBotEntityId(target: NotificationTarget): number | null {
if (target.type === 'telegram') return target.config?.bot_id || null;
if (target.type === 'email') return target.config?.email_bot_id || null;
if (target.type === 'matrix') return target.config?.matrix_bot_id || null;
@@ -57,7 +55,7 @@
function receiverLabel(target: NotificationTarget, recv: TargetReceiver): string {
const c = recv.config || {};
if (target.type === 'telegram') {
return (recv as any).chat_name || c.chat_id || recv.receiver_key || '?';
return recv.chat_name || c.chat_id || recv.receiver_key || '?';
}
if (target.type === 'email') return c.email || recv.receiver_key || '?';
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
@@ -173,7 +171,10 @@
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`);
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// ── Target CRUD ──
@@ -223,7 +224,7 @@
if (formType === 'telegram') {
let botToken = form.bot_token;
if (form.bot_id && !botToken) {
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
const tokenRes = await api<{ token: string }>(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = {
@@ -265,7 +266,7 @@
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -317,7 +318,7 @@
const target = allTargets.find(t => t.id === addingReceiverForTarget);
const botId = target?.config?.bot_id || target?.config?.telegram_bot_id;
if (botId && receiverBotChats[botId]) {
const chat = receiverBotChats[botId].find((c: any) => String(c.chat_id) === String(config.chat_id));
const chat = receiverBotChats[botId].find((c: TelegramChat) => String(c.chat_id) === String(config.chat_id));
if (chat) {
config.chat_name = chat.title || chat.username || '';
if (chat.language_code) config.language_code = chat.language_code;
@@ -369,7 +370,7 @@
async function testReceiver(targetId: number, receiverId: number) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
const res = await api(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -391,108 +392,25 @@
{/if}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-4">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline">→</a></p>
{/if}
</div>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems()} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline">→</a></p>
{/if}
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/if}
<button type="submit" disabled={submitting} 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">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
</form>
</Card>
</div>
<TargetForm
bind:form
bind:formType
{activeType}
{typeGridItems}
{telegramBotItems}
{emailBotItems}
{matrixBotItems}
chatActionItems={chatActionItems()}
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.length}
matrixBotCount={matrixBots.length}
{editing}
{submitting}
{error}
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
/>
{/if}
{#if !showForm && allTargets.length > 0}
@@ -522,7 +440,7 @@
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if (target.receivers || []).length > 0}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} entityId={getBotEntityId(target)} />{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
@@ -533,113 +451,25 @@
</div>
<!-- Receivers list -->
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
{#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id}
<p class="text-xs text-[var(--color-muted-foreground)] italic mb-2">{t('targets.noReceivers')}</p>
{/if}
{#each target.receivers || [] as recv (recv.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={!recv.enabled}>
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name={TYPE_ICONS[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => testReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
onclick={() => toggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => confirmDeleteReceiver = { targetId: target.id, receiver: recv }}
variant="danger"
size={16}
/>
</div>
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => loadReceiverBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
<input bind:value={receiverForm.url} placeholder="https://..."
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] mb-2" />
<input bind:value={receiverForm.headers} placeholder={'{"Authorization": "Bearer ..."}'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"
style={receiverHeadersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if receiverHeadersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{receiverHeadersError}</p>{/if}
{:else if target.type === 'discord' || target.type === 'slack'}
<input bind:value={receiverForm.webhook_url}
placeholder={target.type === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'ntfy'}
<input bind:value={receiverForm.topic} placeholder="my-notifications"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'matrix'}
<input bind:value={receiverForm.room_id} placeholder="!abc123:matrix.org"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{/if}
<div class="flex gap-2 mt-2">
<button type="button" onclick={() => saveReceiver(target.id)} disabled={receiverSubmitting}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90 disabled:opacity-50">
{receiverSubmitting ? t('common.loading') : t('common.save')}
</button>
<button type="button" onclick={() => addingReceiverForTarget = null}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => openReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{/if}
</div>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
/>
</Card>
{/each}
</div>
@@ -0,0 +1,156 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
interface Props {
target: NotificationTarget;
typeIcons: Record<string, string>;
addingReceiverForTarget: number | null;
receiverForm: Record<string, any>;
receiverSubmitting: boolean;
receiverHeadersError: string;
receiverBotChats: Record<number, TelegramChat[]>;
receiverTesting: Record<number, boolean>;
receiverLabel: (target: NotificationTarget, recv: TargetReceiver) => string;
onopenReceiverForm: (targetId: number, targetType: string) => void;
onsaveReceiver: (targetId: number) => void;
oncancelReceiver: () => void;
ontoggleReceiver: (targetId: number, receiver: TargetReceiver) => void;
onremoveReceiver: (targetId: number, receiver: TargetReceiver) => void;
ontestReceiver: (targetId: number, receiverId: number) => void;
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
}
let {
target,
typeIcons,
addingReceiverForTarget,
receiverForm = $bindable(),
receiverSubmitting,
receiverHeadersError,
receiverBotChats,
receiverTesting,
receiverLabel,
onopenReceiverForm,
onsaveReceiver,
oncancelReceiver,
ontoggleReceiver,
onremoveReceiver,
ontestReceiver,
onloadBotChats,
onchangeReceiverForm,
}: Props = $props();
</script>
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
{#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id}
<p class="text-xs text-[var(--color-muted-foreground)] italic mb-2">{t('targets.noReceivers')}</p>
{/if}
{#each target.receivers || [] as recv (recv.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={!recv.enabled}>
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name={typeIcons[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => ontestReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
onclick={() => ontoggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => onremoveReceiver(target.id, recv)}
variant="danger"
size={16}
/>
</div>
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => onloadBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
<input bind:value={receiverForm.url} placeholder="https://..."
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] mb-2" />
<input bind:value={receiverForm.headers} placeholder={'{"Authorization": "Bearer ..."}'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"
style={receiverHeadersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if receiverHeadersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{receiverHeadersError}</p>{/if}
{:else if target.type === 'discord' || target.type === 'slack'}
<input bind:value={receiverForm.webhook_url}
placeholder={target.type === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'ntfy'}
<input bind:value={receiverForm.topic} placeholder="my-notifications"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'matrix'}
<input bind:value={receiverForm.room_id} placeholder="!abc123:matrix.org"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{/if}
<div class="flex gap-2 mt-2">
<button type="button" onclick={() => onsaveReceiver(target.id)} disabled={receiverSubmitting}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90 disabled:opacity-50">
{receiverSubmitting ? t('common.loading') : t('common.save')}
</button>
<button type="button" onclick={oncancelReceiver}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{/if}
</div>
@@ -0,0 +1,172 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
form: {
name: string;
icon: string;
bot_id: number;
bot_token: string;
max_media_to_send: number;
max_media_per_group: number;
media_delay: number;
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
server_url: string;
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
};
formType: string;
activeType: string | null;
typeGridItems: GridItem[];
telegramBotItems: EntityItem[];
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
editing: number | null;
submitting: boolean;
error: string;
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
}
let {
form = $bindable(),
formType = $bindable(),
activeType,
typeGridItems,
telegramBotItems,
emailBotItems,
matrixBotItems,
chatActionItems,
telegramBotCount,
emailBotCount,
matrixBotCount,
editing,
submitting,
error,
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
}: Props = $props();
</script>
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={onsave} class="space-y-4">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
{/if}
</div>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={ontoggleTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
{/if}
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/if}
<button type="submit" disabled={submitting} 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">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
</form>
</Card>
</div>