Files
notify-bridge/frontend/src/routes/targets/+page.svelte
T
alexei.dolgolyov 8651767112 feat: bridge_self bot commands — status, thresholds, reset, health
Adds bot commands for the bridge_self provider so operators can inspect
and manage bridge health from chat: /status, /thresholds, /reset, /health.
Includes Jinja2 templates for both locales, seed data, capability slots,
and a handler that exposes pending deferred backlog plus per-counter
reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
2026-05-16 03:43:48 +03:00

901 lines
31 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ──── Helpers ────
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;
}
if (target.type === 'email' && target.config?.email_bot_id) {
const bot = emailBots.find(b => b.id === target.config.email_bot_id);
return bot?.name || null;
}
if (target.type === 'matrix' && target.config?.matrix_bot_id) {
const bot = matrixBots.find(b => b.id === target.config.matrix_bot_id);
return bot?.name || null;
}
return null;
}
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: 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;
return null;
}
function receiverLabel(target: NotificationTarget, recv: TargetReceiver): string {
const c = recv.config || {};
if (target.type === 'telegram') {
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 || '?';
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', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
const TYPE_ICONS: Record<string, string> = {
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
broadcast: 'mdiBullhorn',
};
const TYPE_DESC_KEYS: Record<string, string> = {
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
broadcast: 'targets.descBroadcast',
};
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
value: tt,
icon: TYPE_ICONS[tt] || 'mdiTarget',
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
tiles.push({
icon: TYPE_ICONS[target.type] || 'mdiTarget',
label: target.type,
tone: 'lavender',
mono: true,
});
const botName = getBotName(target);
if (botName) {
tiles.push({
icon: 'mdiRobot',
label: botName,
tone: 'sky',
});
}
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
icon: 'mdiChat',
label: String(cfg.chat_id),
tone: 'orchid',
mono: true,
});
}
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiLinkVariant',
label: host,
hint: String(cfg.url),
href: String(cfg.url),
tone: 'orchid',
mono: true,
});
}
return tiles;
}
// ──── Derived state ────
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
let filterText = $state('');
let targets = $derived(allTargets.filter(t =>
(!activeType || t.type === activeType) &&
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase()))
));
let telegramBots = $derived(telegramBotsCache.items);
let emailBots = $derived(emailBotsCache.items);
let matrixBots = $derived(matrixBotsCache.items);
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 })));
// ──── 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, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
server_url: 'https://ntfy.sh', auth_token: '',
// Matrix
matrix_bot_id: 0,
// Email
email_bot_id: 0,
// Broadcast
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let error = $state('');
let loaded = $state(false);
let submitting = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl = $state<HTMLElement | undefined>();
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
broadcast: 'Broadcast',
};
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
}
});
async function scrollToForm() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ──── 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>>({});
// Per-target expansion state for the receivers section. Hidden by default.
let expandedTargets = $state<Set<number>>(new SvelteSet());
function isExpanded(id: number): boolean {
return expandedTargets.has(id);
}
function toggleExpanded(id: number) {
if (expandedTargets.has(id)) expandedTargets.delete(id);
else expandedTargets.add(id);
}
function expandTarget(id: number) {
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ──── Effects ────
// Reset form when switching target type tabs
$effect(() => {
activeType; // track
showForm = false;
editing = null;
error = '';
addingReceiverForTarget = null;
});
// ──── Data loading ────
onMount(load);
// ──── Bot grouping ────
type TargetGroup = {
key: string;
type: string;
name: string;
subtitle: string | null;
icon: string;
typeBadge: string | null;
botHref: string | null;
botEntityId: number | null;
muted: boolean;
targets: NotificationTarget[];
};
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
const groupedTargets = $derived.by<TargetGroup[]>(() => {
const groups = new Map<string, TargetGroup>();
for (const tgt of targets) {
const isBotType = BOT_TYPES.has(tgt.type);
const botId = isBotType ? getBotEntityId(tgt) : null;
const key = isBotType
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
: `${tgt.type}:direct`;
let group = groups.get(key);
if (!group) {
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
let name = '';
let subtitle: string | null = null;
let muted = false;
if (isBotType && botId) {
if (tgt.type === 'telegram') {
const bot = telegramBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
icon = bot?.icon || 'mdiSend';
} else if (tgt.type === 'email') {
const bot = emailBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.email || null;
icon = bot?.icon || 'mdiEmailOutline';
} else if (tgt.type === 'matrix') {
const bot = matrixBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.display_name || bot?.homeserver_url || null;
icon = bot?.icon || 'mdiMatrix';
}
} else if (isBotType) {
name = t('targets.groupNoBot');
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
muted = true;
} else {
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
subtitle = t('targets.groupDirect');
muted = true;
}
group = {
key,
type: tgt.type,
name,
subtitle,
icon,
typeBadge,
botHref: isBotType && botId ? getBotHref(tgt) : null,
botEntityId: isBotType ? botId : null,
muted,
targets: [],
};
groups.set(key, group);
}
group.targets.push(tgt);
}
const rank = (g: TargetGroup) => {
if (g.type === 'broadcast') return 4;
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
return 1; // bot-linked
};
return [...groups.values()].sort((a, b) => {
const ra = rank(a), rb = rank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name);
});
});
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
// Tab-filtered: show count of receivers for the active type only.
const total = targets.reduce((acc, t) => acc + (t.receiver_count || 0), 0);
if (total > 0) pills.push({ label: `${total} ${total === 1 ? t('targets.receiver') : t('targets.receivers')}`, tone: 'mint' });
} else {
const types = new Set(targets.map(t => t.type)).size;
if (types > 0) pills.push({ label: `${types} ${t('targets.channelsCount')}`, tone: 'sky' });
}
return pills;
});
async function load() {
try {
await Promise.all([
targetsCache.fetch(true), telegramBotsCache.fetch(),
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadError'));
snackError(loadError);
} finally {
loaded = true;
highlightFromUrl();
}
}
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`);
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
async function discoverReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ──── Target CRUD ────
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
// Auto-select first available bot of the chosen type
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
nameManuallyEdited = false;
editing = null;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
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: '',
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: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
server_url: c.server_url || 'https://ntfy.sh',
auth_token: c.auth_token || '',
// email
email_bot_id: c.email_bot_id || 0,
// matrix
matrix_bot_id: c.matrix_bot_id || 0,
// broadcast
child_target_ids: c.child_target_ids || [],
};
nameManuallyEdited = true;
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
if (submitting) return;
submitting = true;
try {
let config: Record<string, any> = {};
if (formType === 'telegram') {
let botToken = form.bot_token;
if (form.bot_id && !botToken) {
const tokenRes = await api<{ token: string }>(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
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,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
} else if (formType === 'discord' || formType === 'slack') {
config = { username: form.username || undefined };
} else if (formType === 'ntfy') {
config = { server_url: form.server_url, auth_token: form.auth_token || undefined };
} else if (formType === 'email') {
config = { email_bot_id: form.email_bot_id };
} else if (formType === 'matrix') {
config = { matrix_bot_id: form.matrix_bot_id };
} else if (formType === 'broadcast') {
config = { child_target_ids: form.child_target_ids };
}
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
if (formType === 'telegram') body.chat_action = form.chat_action || null;
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
}
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
submitting = false;
}
}
async function test(id: number) {
try {
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: unknown) { snackError(errMsg(err)); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) {
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
const m = errMsg(err);
error = m;
snackError(m);
}
}
// ──── Receiver CRUD ────
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
if (addingReceiverForTarget === targetId) {
addingReceiverForTarget = null;
await tick();
}
addingReceiverForTarget = targetId;
expandTarget(targetId);
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Show what we have immediately (cached list), then actively discover in the
// background so any newly-added chats appear in the palette as soon as
// Telegram returns them.
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId) {
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
discoverReceiverBotChats(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: Record<string, any> = { ...receiverForm };
// Enrich Telegram receiver with chat metadata
if (config.chat_id && addingReceiverForTarget) {
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: 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;
}
}
}
// 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: unknown) {
snackError(errMsg(err));
} 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: unknown) { snackError(errMsg(err)); }
}
async function removeReceiver(targetId: number, receiverId: number) {
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
const tgt = allTargets.find(t => t.id === targetId);
if (!tgt) return;
const disabled = new Set<number>(tgt.config?.disabled_child_ids || []);
if (disabled.has(childId)) disabled.delete(childId);
else disabled.add(childId);
try {
await api(`/targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testReceiver(targetId: number, receiverId: number) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
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: unknown) { snackError(errMsg(err)); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
<PageHeader
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb={t('crumbs.routingTargets')}
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('targets.cancel') : t('targets.addTarget')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if loadError}
<ErrorBanner message={loadError} />
{/if}
{#if showForm}
<div bind:this={formEl}></div>
<TargetForm
bind:form
bind:formType
{activeType}
{typeGridItems}
{telegramBotItems}
{emailBotItems}
{matrixBotItems}
chatActionItems={chatActionItems()}
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.length}
matrixBotCount={matrixBots.length}
broadcastChildItems={allTargets.filter(t => t.type !== 'broadcast' && t.id !== editing).map(t => ({ value: t.id, label: t.name, icon: t.icon || TYPE_ICONS[t.type] || 'mdiTarget', desc: t.type }))}
{editing}
{submitting}
{error}
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
{#if !showForm && allTargets.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#if allTargets.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiTarget" message={t('targets.noTargets')} />
</Card>
{:else if targets.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="targets-list">
{#each groupedTargets as group (group.key)}
<section class="target-group">
<BotGroupHeader
icon={group.icon}
name={group.name}
subtitle={group.subtitle}
targetCount={group.targets.length}
typeBadge={!activeType ? group.typeBadge : null}
botHref={group.botHref}
botEntityId={group.botEntityId}
muted={group.muted}
/>
<div class="target-group__items stagger-children">
{#each group.targets as target (target.id)}
{@const expanded = isExpanded(target.id)}
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center gap-2">
<button
type="button"
class="target-summary"
aria-expanded={expanded}
aria-controls={`target-body-${target.id}`}
onclick={() => toggleExpanded(target.id)}
>
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
<MdiIcon name="mdiChevronRight" size={16} />
</span>
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<span class="target-summary__name">{target.name}</span>
{#if childCount > 0}
<span class="target-summary__count">
<span class="target-summary__count-num">{childCount}</span>
<span class="target-summary__count-label">{childLabel}</span>
</span>
{:else}
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
{/if}
</button>
<MetaStrip tiles={targetTiles(target)} />
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list (collapsible) -->
{#if expanded}
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
<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}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</div>
{/if}
</Card>
{/each}
</div>
</section>
{/each}
</div>
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('targets.confirmDelete')}
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}
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.targets-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.target-group {
display: block;
}
.target-group__items {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-left: 0.85rem;
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
margin-left: 0.55rem;
}
@media (max-width: 640px) {
.target-group__items {
padding-left: 0.4rem;
margin-left: 0.25rem;
}
}
.target-summary {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.1rem 0.25rem 0.1rem 0;
margin: -0.1rem 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: 8px;
transition: background 0.15s ease;
}
@media (min-width: 1024px) {
.target-summary {
flex: 0 1 auto;
max-width: 32rem;
}
}
.target-summary:hover {
background: var(--color-glass-strong);
}
.target-summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.target-summary__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
}
.target-summary__chevron.open {
transform: rotate(90deg);
color: var(--color-primary);
}
.target-summary__icon {
color: var(--color-primary);
display: inline-flex;
flex-shrink: 0;
}
.target-summary__name {
font-weight: 500;
font-size: 0.95rem;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.target-summary__count {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.12rem 0.45rem;
border-radius: 9999px;
background: var(--color-muted);
flex-shrink: 0;
}
.target-summary__count-num {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-foreground);
}
.target-summary__count-label {
text-transform: lowercase;
}
.target-summary__count--empty {
font-style: italic;
font-family: inherit;
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: transparent;
padding: 0.12rem 0.2rem;
}
</style>