a7a2b4efa4
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
542 lines
20 KiB
Svelte
542 lines
20 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 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 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: HTMLElement;
|
|
|
|
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);
|
|
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;
|
|
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: 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 || [],
|
|
};
|
|
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, chat_action: form.chat_action || undefined,
|
|
};
|
|
} 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 };
|
|
}
|
|
|
|
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();
|
|
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 ? `${t('targets.title')} — ${activeType.charAt(0).toUpperCase() + activeType.slice(1)}` : t('targets.title')} description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}>
|
|
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
{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}
|
|
/>
|
|
{/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} />
|