91e5cd58e9
Backend: - Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs - Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data - Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified) - Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint) - Fix API key leak: only attach x-api-key header for internal provider URLs - Validate config ownership in tracker_targets create/update - Fix _response() double-emit of created_at in template/tracking configs - Add per-target-link test endpoints (test, test-periodic, test-memory) Frontend: - Fix orphaned provider on test exception in providers/new - Add submitting guard + disabled state to targets save button - Move test buttons from tracker card to per-target-link rows - Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations) - i18n for dashboard timeAgo and event type badges (EN + RU) - Add required attribute to chat select dropdown in targets - Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono - Standardize empty states with centered icon + text across all 6 list pages - Add stagger-children animation class to all list containers - Fix slide transition duration consistency (200ms everywhere) - Standardize border-radius to rounded-md across all form inputs - Fix providers/new page structure (h2 + mb-8 spacing) - Fix tracker card action row overflow (flex-wrap justify-end) - JinjaEditor dark mode reactivity (recreate editor on theme change) - Add aria-labels to mobile nav items - Make ConfirmModal confirm button label/icon configurable - Remove double error reporting on providers page - Add telegram bot edit functionality (name editing via PUT) - i18n for External Domain label on provider forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
9.1 KiB
Svelte
223 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
|
|
|
let bots = $state<any[]>([]);
|
|
let loaded = $state(false);
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let form = $state({ name: '', icon: '', token: '' });
|
|
let error = $state('');
|
|
let submitting = $state(false);
|
|
let confirmDelete = $state<any>(null);
|
|
|
|
// Per-bot expandable sections
|
|
let chats = $state<Record<number, any[]>>({});
|
|
let chatsLoading = $state<Record<number, boolean>>({});
|
|
let expandedSection = $state<Record<number, string>>({});
|
|
|
|
onMount(load);
|
|
async function load() {
|
|
try { bots = await api('/telegram-bots'); }
|
|
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
|
finally { loaded = true; }
|
|
}
|
|
|
|
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
|
|
function editBot(bot: any) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
|
|
|
|
async function saveBot(e: SubmitEvent) {
|
|
e.preventDefault(); error = ''; submitting = true;
|
|
try {
|
|
if (editing) {
|
|
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name }) });
|
|
snackSuccess(t('snack.botUpdated'));
|
|
} else {
|
|
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
|
snackSuccess(t('snack.botRegistered'));
|
|
}
|
|
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await load();
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
submitting = false;
|
|
}
|
|
|
|
function remove(id: number) {
|
|
confirmDelete = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.botDeleted')); }
|
|
catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { confirmDelete = null; }
|
|
}
|
|
};
|
|
}
|
|
|
|
function toggleSection(botId: number, section: string) {
|
|
if (expandedSection[botId] === section) {
|
|
expandedSection = { ...expandedSection, [botId]: '' };
|
|
return;
|
|
}
|
|
expandedSection = { ...expandedSection, [botId]: section };
|
|
if (section === 'chats') loadChats(botId);
|
|
}
|
|
|
|
async function loadChats(botId: number) {
|
|
chatsLoading = { ...chatsLoading, [botId]: true };
|
|
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
|
|
chatsLoading = { ...chatsLoading, [botId]: false };
|
|
}
|
|
|
|
async function discoverChats(botId: number) {
|
|
chatsLoading = { ...chatsLoading, [botId]: true };
|
|
try {
|
|
chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
|
} catch (err: any) { snackError(err.message); }
|
|
chatsLoading = { ...chatsLoading, [botId]: false };
|
|
}
|
|
|
|
async function deleteChat(botId: number, chatDbId: number) {
|
|
try {
|
|
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
|
chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId);
|
|
snackSuccess(t('telegramBot.chatDeleted'));
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
function copyChatId(e: Event, chatId: string) {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(chatId);
|
|
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
|
}
|
|
|
|
function chatTypeLabel(type: string): string {
|
|
const map: Record<string, string> = {
|
|
private: t('telegramBot.private'),
|
|
group: t('telegramBot.group'),
|
|
supergroup: t('telegramBot.supergroup'),
|
|
channel: t('telegramBot.channel'),
|
|
};
|
|
return map[type] || type;
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.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('common.cancel') : t('telegramBot.addBot')}
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}<Loading />{:else}
|
|
|
|
{#if showForm}
|
|
<Card class="mb-6">
|
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
|
<form onsubmit={saveBot} class="space-y-3">
|
|
<div>
|
|
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
|
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
{#if !editing}
|
|
<div>
|
|
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
|
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
|
</div>
|
|
{/if}
|
|
<button type="submit" disabled={submitting}
|
|
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
{submitting ? t('common.loading') : (editing ? t('common.save') : t('telegramBot.addBot'))}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if bots.length === 0 && !showForm}
|
|
<Card>
|
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
|
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
|
|
<p class="text-sm">{t('telegramBot.noBots')}</p>
|
|
</div>
|
|
</Card>
|
|
{:else}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each bots as bot}
|
|
<Card hover>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
|
|
<p class="font-medium">{bot.name}</p>
|
|
{#if bot.bot_username}
|
|
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
|
{/if}
|
|
</div>
|
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
|
<button onclick={() => toggleSection(bot.id, 'chats')}
|
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
|
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
|
</button>
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chats section -->
|
|
{#if expandedSection[bot.id] === 'chats'}
|
|
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
|
{#if chatsLoading[bot.id]}
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
|
{:else if (chats[bot.id] || []).length === 0}
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
|
{:else}
|
|
<div class="space-y-1">
|
|
{#each chats[bot.id] as chat}
|
|
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
|
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
|
title={t('telegramBot.clickToCopy')}
|
|
role="button" tabindex="0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
|
</div>
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<button onclick={() => discoverChats(bot.id)}
|
|
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>
|
|
</div>
|
|
{/if}
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|