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:
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user