feat: collapsible chart, paginator controls, localized template slots
- Dashboard chart collapsible with state persisted in localStorage - Events per page user-controlled (5/10/20/50) via select, persisted - Paginator rendered both above and below event list (shared snippet) - Removed viewport-based page size calculation - Template slot descriptions localized (templateSlot.* i18n keys) - Preview As target selector expanded: email, discord, slack added - Tighter event item spacing
This commit is contained in:
@@ -105,8 +105,11 @@ export const chatActionItems = (): GridItem[] => [
|
||||
// --- Preview target type ---
|
||||
|
||||
export const previewTargetTypeItems = (): GridItem[] => [
|
||||
{ value: 'telegram', icon: 'mdiSend', label: t('targets.typeTelegram'), desc: t('gridDesc.previewTelegram') },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook'), desc: t('gridDesc.previewWebhook') },
|
||||
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram', desc: t('gridDesc.previewTelegram') },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook', desc: t('gridDesc.previewWebhook') },
|
||||
{ value: 'email', icon: 'mdiEmailOutline', label: 'Email', desc: t('gridDesc.previewEmail') },
|
||||
{ value: 'discord', icon: 'mdiChat', label: 'Discord', desc: t('gridDesc.previewDiscord') },
|
||||
{ value: 'slack', icon: 'mdiSlack', label: 'Slack', desc: t('gridDesc.previewSlack') },
|
||||
];
|
||||
|
||||
// --- Provider type items (derived from descriptor registry) ---
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Events",
|
||||
"chart": "Event chart",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
"justNow": "just now",
|
||||
@@ -837,6 +838,49 @@
|
||||
"noFilterResults": "No items match the current filter.",
|
||||
"redirecting": "Redirecting..."
|
||||
},
|
||||
"templateSlot": {
|
||||
"message_assets_added": "New assets added to album",
|
||||
"message_assets_removed": "Assets removed from album",
|
||||
"message_collection_renamed": "Album renamed",
|
||||
"message_collection_deleted": "Album deleted",
|
||||
"message_sharing_changed": "Sharing status changed",
|
||||
"periodic_summary_message": "Periodic album summary",
|
||||
"scheduled_assets_message": "Scheduled asset delivery",
|
||||
"memory_mode_message": "On This Day memories",
|
||||
"message_push": "Code pushed to repository",
|
||||
"message_issue_opened": "Issue opened",
|
||||
"message_issue_closed": "Issue closed",
|
||||
"message_issue_commented": "Comment on issue",
|
||||
"message_pr_opened": "Pull request opened",
|
||||
"message_pr_closed": "Pull request closed",
|
||||
"message_pr_merged": "Pull request merged",
|
||||
"message_pr_commented": "Comment on pull request",
|
||||
"message_release_published": "Release published",
|
||||
"message_scheduled_message": "Scheduled message",
|
||||
"message_card_created": "Card created",
|
||||
"message_card_updated": "Card updated",
|
||||
"message_card_moved": "Card moved between lists",
|
||||
"message_card_deleted": "Card deleted",
|
||||
"message_card_commented": "Comment added to card",
|
||||
"message_comment_updated": "Comment updated",
|
||||
"message_board_created": "Board created",
|
||||
"message_board_updated": "Board updated",
|
||||
"message_board_deleted": "Board deleted",
|
||||
"message_list_created": "List created",
|
||||
"message_list_updated": "List updated",
|
||||
"message_list_deleted": "List deleted",
|
||||
"message_attachment_created": "Attachment added to card",
|
||||
"message_card_label_added": "Label added to card",
|
||||
"message_task_completed": "Task completed",
|
||||
"message_ups_online": "UPS back on mains power",
|
||||
"message_ups_on_battery": "UPS switched to battery",
|
||||
"message_ups_low_battery": "Battery critically low",
|
||||
"message_ups_battery_restored": "Battery charge recovered",
|
||||
"message_ups_comms_lost": "Communication with UPS lost",
|
||||
"message_ups_comms_restored": "Communication with UPS restored",
|
||||
"message_ups_replace_battery": "Battery needs replacement",
|
||||
"message_ups_overload": "UPS load exceeded capacity"
|
||||
},
|
||||
"gridDesc": {
|
||||
"sortNone": "No sorting applied",
|
||||
"sortDate": "Sort by creation date",
|
||||
@@ -874,6 +918,9 @@
|
||||
"chatActionRecordVoice": "Show recording voice...",
|
||||
"previewTelegram": "Preview with Telegram HTML format",
|
||||
"previewWebhook": "Preview as plain text",
|
||||
"previewEmail": "Preview with email HTML format",
|
||||
"previewDiscord": "Preview with Discord markdown",
|
||||
"previewSlack": "Preview with Slack markdown",
|
||||
"providerImmich": "Self-hosted photo server",
|
||||
"providerGitea": "Self-hosted Git service",
|
||||
"providerPlanka": "Self-hosted Kanban board",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "События",
|
||||
"chart": "График событий",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
"justNow": "только что",
|
||||
@@ -837,6 +838,49 @@
|
||||
"noFilterResults": "Нет элементов, соответствующих фильтру.",
|
||||
"redirecting": "Перенаправление..."
|
||||
},
|
||||
"templateSlot": {
|
||||
"message_assets_added": "Новые файлы добавлены в альбом",
|
||||
"message_assets_removed": "Файлы удалены из альбома",
|
||||
"message_collection_renamed": "Альбом переименован",
|
||||
"message_collection_deleted": "Альбом удалён",
|
||||
"message_sharing_changed": "Статус общего доступа изменён",
|
||||
"periodic_summary_message": "Периодическая сводка альбома",
|
||||
"scheduled_assets_message": "Запланированная отправка файлов",
|
||||
"memory_mode_message": "Воспоминания «В этот день»",
|
||||
"message_push": "Код отправлен в репозиторий",
|
||||
"message_issue_opened": "Issue открыт",
|
||||
"message_issue_closed": "Issue закрыт",
|
||||
"message_issue_commented": "Комментарий к issue",
|
||||
"message_pr_opened": "Pull request открыт",
|
||||
"message_pr_closed": "Pull request закрыт",
|
||||
"message_pr_merged": "Pull request слит",
|
||||
"message_pr_commented": "Комментарий к pull request",
|
||||
"message_release_published": "Релиз опубликован",
|
||||
"message_scheduled_message": "Запланированное сообщение",
|
||||
"message_card_created": "Карточка создана",
|
||||
"message_card_updated": "Карточка обновлена",
|
||||
"message_card_moved": "Карточка перемещена",
|
||||
"message_card_deleted": "Карточка удалена",
|
||||
"message_card_commented": "Комментарий к карточке",
|
||||
"message_comment_updated": "Комментарий обновлён",
|
||||
"message_board_created": "Доска создана",
|
||||
"message_board_updated": "Доска обновлена",
|
||||
"message_board_deleted": "Доска удалена",
|
||||
"message_list_created": "Список создан",
|
||||
"message_list_updated": "Список обновлён",
|
||||
"message_list_deleted": "Список удалён",
|
||||
"message_attachment_created": "Вложение добавлено",
|
||||
"message_card_label_added": "Метка добавлена к карточке",
|
||||
"message_task_completed": "Задача выполнена",
|
||||
"message_ups_online": "ИБП на сетевом питании",
|
||||
"message_ups_on_battery": "ИБП перешёл на батарею",
|
||||
"message_ups_low_battery": "Батарея критически разряжена",
|
||||
"message_ups_battery_restored": "Заряд батареи восстановлен",
|
||||
"message_ups_comms_lost": "Связь с ИБП потеряна",
|
||||
"message_ups_comms_restored": "Связь с ИБП восстановлена",
|
||||
"message_ups_replace_battery": "Батарея требует замены",
|
||||
"message_ups_overload": "ИБП перегружен"
|
||||
},
|
||||
"gridDesc": {
|
||||
"sortNone": "Без сортировки",
|
||||
"sortDate": "По дате создания",
|
||||
@@ -874,6 +918,9 @@
|
||||
"chatActionRecordVoice": "Показать «записывает голос...»",
|
||||
"previewTelegram": "Предпросмотр в формате Telegram HTML",
|
||||
"previewWebhook": "Предпросмотр как текст",
|
||||
"previewEmail": "Предпросмотр в формате Email HTML",
|
||||
"previewDiscord": "Предпросмотр в формате Discord",
|
||||
"previewSlack": "Предпросмотр в формате Slack",
|
||||
"providerImmich": "Фотосервер для самостоятельного размещения",
|
||||
"providerGitea": "Git-сервер для самостоятельного размещения",
|
||||
"providerPlanka": "Канбан-доска для самостоятельного размещения",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
@@ -15,6 +16,14 @@
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
import type { DashboardStatus } from '$lib/types';
|
||||
|
||||
const CHART_KEY = 'dashboard_chart_visible';
|
||||
let chartVisible = $state(typeof localStorage !== 'undefined' ? localStorage.getItem(CHART_KEY) !== 'false' : true);
|
||||
function toggleChart() {
|
||||
chartVisible = !chartVisible;
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(CHART_KEY, String(chartVisible));
|
||||
}
|
||||
|
||||
let status = $state<DashboardStatus | null>(null);
|
||||
let providers = $derived(providersCache.items);
|
||||
const providerFilterItems = $derived([
|
||||
@@ -36,22 +45,21 @@
|
||||
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
|
||||
let filterSearch = $state('');
|
||||
let filterSort = $state('newest');
|
||||
let eventsLimit = $state(calcPageSize());
|
||||
|
||||
const EVENTS_PER_PAGE_KEY = 'dashboard_events_per_page';
|
||||
function loadEventsPerPage(): number {
|
||||
if (typeof localStorage === 'undefined') return 10;
|
||||
const stored = localStorage.getItem(EVENTS_PER_PAGE_KEY);
|
||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
||||
}
|
||||
|
||||
let eventsLimit = $state(loadEventsPerPage());
|
||||
let eventsOffset = $state(0);
|
||||
let eventsLoading = $state(false);
|
||||
|
||||
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
||||
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
||||
|
||||
/** Calculate how many event rows fit in the remaining viewport space. */
|
||||
function calcPageSize(): number {
|
||||
if (typeof window === 'undefined') return 8;
|
||||
const EVENT_ROW_HEIGHT = 50;
|
||||
const FIXED_OVERHEAD = 700; // slightly more for chart in Events section
|
||||
const available = window.innerHeight - FIXED_OVERHEAD;
|
||||
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
|
||||
}
|
||||
|
||||
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||
if (to === 0) { setter(0); return; }
|
||||
const start = performance.now();
|
||||
@@ -126,27 +134,10 @@
|
||||
searchTimeout = setTimeout(applyFilters, 300);
|
||||
}
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
function onResize() {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
const newLimit = calcPageSize();
|
||||
if (newLimit !== eventsLimit) {
|
||||
eventsLimit = newLimit;
|
||||
eventsOffset = 0;
|
||||
loadEvents();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
eventsLimit = calcPageSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
loadInitial();
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
clearTimeout(searchTimeout);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -281,8 +272,56 @@
|
||||
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
||||
</div>
|
||||
|
||||
<!-- Chart (now inside Events section, affected by filters) -->
|
||||
<EventChart days={chartDays} />
|
||||
<!-- Chart -->
|
||||
<button type="button" onclick={toggleChart}
|
||||
class="flex items-center gap-1.5 text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors mb-2 cursor-pointer">
|
||||
<MdiIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
||||
<MdiIcon name="mdiChartBar" size={14} />
|
||||
{t('dashboard.chart')}
|
||||
</button>
|
||||
{#if chartVisible}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<EventChart days={chartDays} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet paginator()}
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
{#if totalPages > 1}
|
||||
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronLeft" size={16} />
|
||||
</button>
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<button onclick={() => goToPage(page)}
|
||||
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
||||
{page}
|
||||
</button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronRight" size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<select value={eventsLimit}
|
||||
onchange={(e) => { const v = parseInt((e.target as HTMLSelectElement).value, 10); eventsLimit = v; eventsOffset = 0; if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_PER_PAGE_KEY, String(v)); loadEvents(); }}
|
||||
class="ml-2 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)] text-[var(--color-foreground)]">
|
||||
{#each [5, 10, 20, 50] as n}
|
||||
<option value={n}>{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mb-3">
|
||||
{@render paginator()}
|
||||
</div>
|
||||
|
||||
{#if eventsLoading}
|
||||
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
||||
@@ -328,31 +367,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Paginator -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-center gap-1 mt-4">
|
||||
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronLeft" size={16} />
|
||||
</button>
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<button onclick={() => goToPage(page)}
|
||||
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
||||
{page}
|
||||
</button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronRight" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Bottom paginator -->
|
||||
<div class="mt-4">
|
||||
{@render paginator()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -365,10 +383,10 @@
|
||||
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
|
||||
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
||||
.event-timeline { display: flex; flex-direction: column; }
|
||||
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
|
||||
.event-item { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.5rem; }
|
||||
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
|
||||
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
|
||||
.event-content { flex: 1; min-width: 0; padding: 0.5rem 0.875rem; border-radius: 0.625rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
||||
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
||||
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
||||
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
||||
</style>
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
/** Clean up validate timers on unmount */
|
||||
onMount(() => {
|
||||
return () => {
|
||||
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
let dateFormatPreview = $state<Record<string, string | null>>({});
|
||||
let expandedSlots = $state<Set<string>>(new Set());
|
||||
let slotFilter = $state('');
|
||||
@@ -166,14 +172,20 @@
|
||||
);
|
||||
|
||||
// Group slots into event messages vs scheduled messages based on slot name prefix
|
||||
function slotDescription(s: {name: string, description: string}): string {
|
||||
const key = `templateSlot.${s.name}`;
|
||||
const localized = t(key);
|
||||
return localized !== key ? localized : s.description;
|
||||
}
|
||||
|
||||
let templateSlots = $derived([
|
||||
{ group: 'eventMessages', slots: notificationSlots
|
||||
.filter(s => s.name.startsWith('message_'))
|
||||
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: s.description, rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
|
||||
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: slotDescription(s), rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
|
||||
},
|
||||
{ group: 'scheduledMessages', slots: notificationSlots
|
||||
.filter(s => !s.name.startsWith('message_'))
|
||||
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: s.description, rows: 6, isDateFormat: false }))
|
||||
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: slotDescription(s), rows: 6, isDateFormat: false }))
|
||||
},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true },
|
||||
@@ -418,7 +430,7 @@
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.description}
|
||||
|
||||
Reference in New Issue
Block a user