5bd63a2191
Mirror the providers form pattern (defaultName tied to type) across bots, targets, trackers, actions, and configs. Each form now derives form.name from the selected type or provider while the user hasn't manually edited it; switching to edit-mode flips the manualEdited flag so existing names are preserved. Defaults: bots → "<Type> Bot"; targets → type label; notification trackers → "<provider> Tracker"; command trackers → "<provider> Commands"; actions → "<provider> <Action Type>"; tracking/template/ command/command-template configs → "<descriptor.defaultName> <Suffix>". TargetForm and TrackerForm grew an optional onnameinput prop so parents can flag manual edits in subform inputs.
581 lines
22 KiB
Svelte
581 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { page } from '$app/state';
|
|
import { api, getBlockedBy, type BlockedByDetail } 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 CrossLink from '$lib/components/CrossLink.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';
|
|
|
|
// ── 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),
|
|
})));
|
|
|
|
// ── 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>>({});
|
|
|
|
// ── Effects ──
|
|
|
|
// Reset form when switching target type tabs
|
|
$effect(() => {
|
|
activeType; // track
|
|
showForm = false;
|
|
editing = null;
|
|
error = '';
|
|
addingReceiverForTarget = null;
|
|
});
|
|
|
|
// ── Data loading ──
|
|
|
|
onMount(load);
|
|
|
|
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: any) {
|
|
loadError = err.message || 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); }
|
|
}
|
|
|
|
// ── 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: any) {
|
|
error = err.message;
|
|
snackError(err.message);
|
|
} 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: any) { snackError(err.message); }
|
|
}
|
|
|
|
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: any) {
|
|
const bb = getBlockedBy(err);
|
|
if (bb) { blockedBy = bb; return; }
|
|
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: 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: 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 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: any) { snackError(err.message); }
|
|
}
|
|
|
|
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: any) { snackError(err.message); }
|
|
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="Routing · Targets"
|
|
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="space-y-3 stagger-children">
|
|
{#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.type === 'broadcast' && target.child_targets?.length}
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
|
{:else if target.type !== 'broadcast' && (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} {t('targets.receivers')}</span>
|
|
{/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">
|
|
<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 -->
|
|
<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}
|
|
/>
|
|
</Card>
|
|
{/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} />
|