feat: entity cache system, nav UX improvements, split CLAUDE.md

- Add $state-based entity cache layer with 30s TTL, request deduplication,
  and local mutation helpers (entity-cache.svelte.ts + caches.svelte.ts)
- Wire all 10 page components to use shared caches for cross-page data
- Add slide animation for nav tree expand/collapse with rotating chevron
- Remove aggregate count badges from container nav nodes (keep on leaves)
- Convert Targets from flat leaf to group with per-type children
  (Telegram, Webhook, Email, Discord, Slack, ntfy, Matrix)
- Add URL-based type filtering on Targets page with per-type descriptions
- Add Bots group children for Email and Matrix alongside Telegram
- Tab-based routing for bots page (?tab=telegram/email/matrix)
- Add per-type target counts and email/matrix bot counts to /status/counts
- Split CLAUDE.md into focused context files under .claude/docs/
- Fix .gitignore: scope lib/ to root, allow .claude/docs/ tracking
- Clear all caches on logout
- Reset form state when switching target type tabs
This commit is contained in:
2026-03-21 23:35:50 +03:00
parent 2c740ff2d2
commit 563716fa76
25 changed files with 551 additions and 155 deletions
+28 -9
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api } from '$lib/api';
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';
@@ -21,11 +23,17 @@
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
};
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',
};
let targets = $state<NotificationTarget[]>([]);
let bots = $state<TelegramBot[]>([]);
let emailBots = $state<EmailBot[]>([]);
let matrixBots = $state<MatrixBot[]>([]);
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);
let bots = $derived(telegramBotsCache.items);
let emailBots = $derived(emailBotsCache.items);
let matrixBots = $derived(matrixBotsCache.items);
let botChats = $state<Record<number, TelegramChat[]>>({});
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -51,11 +59,20 @@
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
// Reset form when switching target type tabs
$effect(() => {
activeType; // track
showForm = false;
editing = null;
error = '';
});
onMount(load);
async function load() {
try {
[targets, bots, emailBots, matrixBots] = await Promise.all([
api('/targets'), api('/telegram-bots'), api('/email-bots'), api('/matrix-bots'),
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; }
@@ -66,7 +83,7 @@
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
function openNew() { form = defaultForm(); formType = activeType || 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
const c = tgt.config || {};
@@ -152,7 +169,7 @@
}
</script>
<PageHeader title={t('targets.title')} description={t('targets.description')}>
<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')}
@@ -170,6 +187,7 @@
<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={save} class="space-y-4">
{#if !activeType}
<div>
<label for="tgt-type" class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<select id="tgt-type" bind:value={formType}
@@ -179,6 +197,7 @@
{/each}
</select>
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
@@ -359,7 +378,7 @@
<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>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
{#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}
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">