feat: comprehensive code review fixes + receivers-only architecture

Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
+313 -133
View File
@@ -20,7 +20,9 @@
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
// ── Helpers ──
function getBotName(target: any): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -39,10 +41,10 @@
}
function getBotHref(target: any): string {
if (target.type === 'telegram') return '/bots';
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';
return '/bots?tab=telegram';
}
function getBotEntityId(target: any): number | null {
@@ -52,6 +54,24 @@
return null;
}
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 || '?';
}
if (target.type === 'email') return c.email || recv.receiver_key || '?';
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
if (target.type === 'discord' || target.type === 'slack') {
const url = c.webhook_url || recv.receiver_key || '';
return url.length > 50 ? url.substring(0, 50) + '...' : url || '?';
}
if (target.type === 'ntfy') return c.topic || recv.receiver_key || '?';
if (target.type === 'matrix') return c.room_id || recv.receiver_key || '?';
return recv.receiver_key || '?';
}
// ── Constants ──
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
type TargetType = typeof ALL_TYPES[number];
const TYPE_ICONS: Record<string, string> = {
@@ -69,6 +89,8 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
@@ -78,39 +100,56 @@
const telegramBotItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
let botChats = $state<Record<number, TelegramChat[]>>({});
// ── Target form state ──
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<TargetType>('telegram');
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack
webhook_url: '', username: '',
// ntfy
server_url: 'https://ntfy.sh', topic: '', auth_token: '', priority: 3,
// Discord/Slack shared settings
username: '',
// ntfy shared settings
server_url: 'https://ntfy.sh', auth_token: '',
// Matrix
matrix_bot_id: 0, room_id: '',
matrix_bot_id: 0,
// Email
email_bot_id: 0, email: '',
email_bot_id: 0,
});
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
let loaded = $state(false);
let submitting = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
// ── Receiver inline form state ──
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
let receiverSubmitting = $state(false);
let receiverBotChats = $state<Record<number, TelegramChat[]>>({});
let receiverHeadersError = $state('');
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// ── Effects ──
// Reset form when switching target type tabs
$effect(() => {
activeType; // track
showForm = false;
editing = null;
error = '';
addingReceiverForTarget = null;
});
// ── Data loading ──
onMount(load);
async function load() {
try {
@@ -119,53 +158,59 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; highlightFromUrl(); }
}
async function loadBotChats() {
if (!form.bot_id) return;
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
// Auto-load chats when bot changes via EntitySelect
let _prevBotId = 0;
$effect(() => {
if (showForm && form.bot_id && form.bot_id !== _prevBotId) {
_prevBotId = form.bot_id;
loadBotChats();
} catch (err: any) {
loadError = err.message || t('common.loadError');
snackError(loadError);
} finally {
loaded = true;
highlightFromUrl();
}
});
}
function openNew() { form = defaultForm(); formType = activeType || 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
}
// ── Target CRUD ──
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
editing = null;
showTelegramSettings = false;
showForm = true;
}
async function edit(tgt: NotificationTarget) {
formType = tgt.type as TargetType;
const c = tgt.config || {};
form = {
name: tgt.name, icon: tgt.icon || '',
// telegram
bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '',
bot_id: c.bot_id || 0, bot_token: '',
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
// webhook
url: c.url || '', headers: '',
// discord/slack
webhook_url: c.webhook_url || '', username: c.username || '',
username: c.username || '',
// ntfy
server_url: c.server_url || 'https://ntfy.sh', topic: c.topic || '',
auth_token: c.auth_token || '', priority: c.priority ?? 3,
server_url: c.server_url || 'https://ntfy.sh',
auth_token: c.auth_token || '',
// email
email_bot_id: c.email_bot_id || 0, email: c.email || '',
email_bot_id: c.email_bot_id || 0,
// matrix
matrix_bot_id: c.matrix_bot_id || 0, room_id: c.room_id || '',
matrix_bot_id: c.matrix_bot_id || 0,
};
editing = tgt.id; showTelegramSettings = false; showForm = true;
if (form.bot_id) await loadBotChats();
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; headersError = '';
e.preventDefault();
error = '';
if (submitting) return;
submitting = true;
try {
@@ -177,38 +222,43 @@
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
config = {
...(botToken ? { bot_token: botToken } : {}),
bot_id: form.bot_id || undefined,
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined };
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
};
} else if (formType === 'webhook') {
let parsedHeaders = {};
if (form.headers) {
try { parsedHeaders = JSON.parse(form.headers); }
catch { headersError = t('common.headersInvalid'); return; }
}
config = { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
config = { ai_captions: form.ai_captions };
} else if (formType === 'discord' || formType === 'slack') {
config = { webhook_url: form.webhook_url, username: form.username || undefined };
config = { username: form.username || undefined };
} else if (formType === 'ntfy') {
config = { server_url: form.server_url, topic: form.topic, auth_token: form.auth_token || undefined };
config = { server_url: form.server_url, auth_token: form.auth_token || undefined };
} else if (formType === 'email') {
config = { email_bot_id: form.email_bot_id, email: form.email };
config = { email_bot_id: form.email_bot_id };
} else if (formType === 'matrix') {
config = { matrix_bot_id: form.matrix_bot_id, room_id: form.room_id };
config = { matrix_bot_id: form.matrix_bot_id };
}
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; await load();
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
} catch (err: any) {
error = err.message;
snackError(err.message);
} finally {
submitting = false;
}
}
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
@@ -216,9 +266,98 @@
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
}
async function remove(id: number) {
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.targetDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
error = err.message;
snackError(err.message);
}
}
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
addingReceiverForTarget = targetId;
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
receiverForm = { url: '', headers: '' };
} else if (targetType === 'discord' || targetType === 'slack') {
receiverForm = { webhook_url: '' };
} else if (targetType === 'ntfy') {
receiverForm = { topic: '' };
} else if (targetType === 'matrix') {
receiverForm = { room_id: '' };
}
}
async function saveReceiver(targetId: number) {
if (receiverSubmitting) return;
receiverSubmitting = true;
receiverHeadersError = '';
try {
const config = { ...receiverForm };
// Parse headers JSON for webhook
if ('headers' in config && typeof config.headers === 'string') {
if (config.headers) {
try { config.headers = JSON.parse(config.headers); }
catch { receiverHeadersError = t('common.headersInvalid'); return; }
} else {
delete config.headers;
}
}
await api(`/targets/${targetId}/receivers`, {
method: 'POST',
body: JSON.stringify({ name: '', config }),
});
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} finally {
receiverSubmitting = false;
}
}
async function toggleReceiver(targetId: number, receiver: TargetReceiver) {
try {
await api(`/targets/${targetId}/receivers/${receiver.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !receiver.enabled }),
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
}
async function removeReceiver(targetId: number, receiverId: number) {
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
}
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' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -258,32 +397,10 @@
<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" class="underline"></a></p>
<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>
{#if form.bot_id}
<div>
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
{#if (botChats[form.bot_id] || []).length > 0}
<select id="tgt-chat" bind:value={form.chat_id} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="">{t('telegramBot.selectChat')}</option>
{#each botChats[form.bot_id] as chat}
<option value={chat.chat_id}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.chat_id}]</option>
{/each}
</select>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
</p>
{:else}
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
{/if}
</div>
{/if}
<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">
@@ -317,22 +434,7 @@
</div>
{/if}
</div>
{:else if formType === 'webhook'}
<div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." 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-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if headersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{headersError}</p>{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-wh" class="block text-sm font-medium mb-1">{formType === 'discord' ? 'Discord' : 'Slack'} Webhook URL</label>
<input id="tgt-wh" bind:value={form.webhook_url} required placeholder={formType === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
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-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
@@ -344,11 +446,6 @@
<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-topic" class="block text-sm font-medium mb-1">{t('targets.ntfyTopic')}</label>
<input id="tgt-ntfy-topic" bind:value={form.topic} required placeholder="my-notifications"
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')}
@@ -359,27 +456,17 @@
<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" class="underline"></a></p>
<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>
<div>
<label for="tgt-email" class="block text-sm font-medium mb-1">{t('targets.recipientEmail')}</label>
<input id="tgt-email" bind:value={form.email} required type="email" placeholder="recipient@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</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" class="underline"></a></p>
<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>
<div>
<label for="tgt-room" class="block text-sm font-medium mb-1">{t('targets.matrixRoomId')}</label>
<input id="tgt-room" bind:value={form.room_id} required placeholder="!abc123:matrix.org"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</div>
{/if}
{#if formType === 'telegram'}
@@ -398,35 +485,18 @@
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target}
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<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.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</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}
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{#if target.type === 'telegram'}
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
{#if target.config?.chat_action}
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
{/if}
{:else if target.type === 'webhook'}
{target.config?.url || ''}
{:else if target.type === 'discord' || target.type === 'slack'}
{target.config?.webhook_url ? target.config.webhook_url.substring(0, 50) + '...' : ''}
{:else if target.type === 'ntfy'}
{target.config?.server_url || 'ntfy.sh'} / {target.config?.topic || ''}
{:else if target.type === 'email'}
{target.config?.email || ''}
{:else if target.type === 'matrix'}
{target.config?.room_id || ''}
{/if}
</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
@@ -434,6 +504,109 @@
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</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>
</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 chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
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.chat_id}`,
}))}
{#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>
</Card>
{/each}
</div>
@@ -447,3 +620,10 @@
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>
<ConfirmModal
open={!!confirmDeleteReceiver}
message={t('targets.confirmDeleteReceiver')}
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null}
/>