feat(redesign): roll subpage hero across all pages + Aurora Button + JinjaEditor + pulse fix

Big batch — every secondary page now wears the same glass-card hero
that landed on Providers earlier:

- notification-trackers, tracking-configs, template-configs
- command-trackers, command-configs, command-template-configs
- targets (with active-tab title), actions
- bots (telegram / email / matrix tabs)
- settings, settings/backup, users

Each page picks an italic-em emphasis word, an editorial crumb (e.g.
'Routing · Notification', 'Operators · Bots', 'System · Maintenance'),
a count meter, and entity-specific status pills derived from live
data: 'X armed / Y paused' for trackers and actions, 'X types' for
configs/templates, 'X channels' or '$N receivers' for targets.

Other changes in this commit:

- Button.svelte: redesigned. Primary variant becomes a real Aurora
  CTA — gradient lavender → orchid pill, 40px tall md / 34px sm,
  inset highlight, lift + glow on hover. Secondary, danger, ghost
  variants reworked to match. The 'Add <Type>' button on every page
  now reads as the page's primary action instead of a flat lavender
  rectangle.
- JinjaEditor: overrode oneDark's hardcoded background with
  !important so the editor surface picks up var(--color-input-bg).
  Gutters / scroller / selection / autocomplete tooltip all match
  Aurora glass tokens now. Template editors stop visually clashing
  with the surrounding panel.
- Aurora pulse dot: rewritten as a self-contained box-shadow glow
  pulse (no transform, no pseudo-element). The dot's bounding box is
  now stable so ancestors with overflow:hidden can never clip the
  visible dot — only the (decorative) outer glow halo. Fixes the
  'half-moon clipping' on the dashboard 'On watch' deck.
- topbar-action.svelte.ts left in tree but unused (topbar CTA was
  reverted per your call). Will clean up in a later commit.
- Form input baseline styling moved into app.css (rounded 0.625rem,
  glass background, hover/focus rings) so untouched filter inputs
  on the per-type pages stop looking out of place.

i18n: emphasis / countLabel / armed / paused / receiver / receivers
/ channelsCount keys added across en + ru.

Build clean: 0 errors, 61 pre-existing a11y warnings unchanged.
This commit is contained in:
2026-04-25 02:52:01 +03:00
parent 9733e5c122
commit d662b50925
22 changed files with 529 additions and 76 deletions
+93 -14
View File
@@ -181,19 +181,51 @@ body::after {
[data-theme="light"] body::before { opacity: 0.85; }
/* Form controls */
/* Form controls — Aurora-native defaults */
input, select, textarea {
color: var(--color-foreground);
background-color: var(--color-input-bg);
border-color: var(--color-rule-strong);
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
font-family: var(--font-sans);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
/* Default text inputs / search / textarea: comfortable padding.
`<input type="checkbox">` and `<input type="radio">` are excluded so
they keep their native compact sizing. Any explicit `padding`/`p-*`
utility from a callsite still wins. */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]),
textarea {
padding: 0.55rem 0.85rem;
font-size: 0.875rem;
}
select {
padding: 0.55rem 2.2rem 0.55rem 0.85rem;
font-size: 0.875rem;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236f6c92' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 12px;
}
input:hover:not(:focus-visible):not([disabled]),
select:hover:not(:focus-visible):not([disabled]),
textarea:hover:not(:focus-visible):not([disabled]) {
border-color: var(--color-rule-strong);
background-color: var(--color-glass-strong);
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 12px var(--color-glow);
box-shadow: 0 0 0 3px var(--color-glow);
}
input::placeholder, textarea::placeholder {
color: var(--color-muted-foreground);
}
button:focus-visible, a:focus-visible {
@@ -276,9 +308,41 @@ button:focus-visible, a:focus-visible {
to { opacity: 1; transform: translateY(0); }
}
@keyframes aurora-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.6; }
@keyframes aurora-pulse-glow-mint {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-mint) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-mint) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-mint) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-mint) 25%, transparent);
}
}
@keyframes aurora-pulse-glow-citrus {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-citrus) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-citrus) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-citrus) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-citrus) 25%, transparent);
}
}
@keyframes aurora-pulse-glow-coral {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-coral) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-coral) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-coral) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-coral) 25%, transparent);
}
}
.animate-fade-slide-in {
@@ -343,15 +407,30 @@ button:focus-visible, a:focus-visible {
opacity: 1;
}
/* Live pulse dot — for "live" / armed indicators */
/* Live pulse dot — for "live" / armed indicators.
Pulse is a self-contained box-shadow glow on the dot. No transform,
no pseudo-element — the dot's own bounding box never changes, so
ancestors with overflow:hidden can only clip the (decorative) glow,
never the dot itself. */
.aurora-pulse {
width: 7px; height: 7px;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--color-mint);
box-shadow: 0 0 8px var(--color-mint);
display: inline-block;
animation: aurora-pulse 1.4s ease-in-out infinite;
flex-shrink: 0;
animation: aurora-pulse-glow-mint 1.6s ease-in-out infinite;
}
.aurora-pulse.warn {
background: var(--color-citrus);
animation-name: aurora-pulse-glow-citrus;
}
.aurora-pulse.error {
background: var(--color-coral);
animation-name: aurora-pulse-glow-coral;
}
.aurora-pulse.idle {
background: var(--color-muted-foreground);
box-shadow: none;
opacity: 0.5;
animation: none;
}
.aurora-pulse.warn { background: var(--color-citrus); box-shadow: 0 0 8px var(--color-citrus); }
.aurora-pulse.error { background: var(--color-coral); box-shadow: 0 0 8px var(--color-coral); }
.aurora-pulse.idle { background: var(--color-muted-foreground); box-shadow: none; opacity: 0.5; animation: none; }
+48 -13
View File
@@ -21,10 +21,10 @@
class?: string;
} = $props();
const baseClasses = 'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50';
const baseClasses = 'aurora-btn inline-flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 disabled:pointer-events-none';
const sizeClasses: Record<string, string> = {
sm: 'px-2.5 py-1 text-xs',
md: 'px-4 py-2',
sm: 'aurora-btn--sm',
md: 'aurora-btn--md',
};
const variantClasses: Record<string, string> = {
primary: 'btn-primary',
@@ -49,37 +49,72 @@
{/if}
<style>
.btn-primary {
background: var(--color-primary);
color: var(--color-primary-foreground);
.aurora-btn {
border-radius: 12px;
letter-spacing: -0.005em;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
.aurora-btn--sm {
padding: 0 0.95rem;
height: 34px;
font-size: 0.82rem;
}
.aurora-btn--md {
padding: 0 1.15rem;
height: 40px;
font-size: 0.875rem;
}
/* Primary — gradient lavender→orchid pill, the page's main CTA. */
.btn-primary {
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: white;
border: 0;
box-shadow:
0 6px 20px -8px var(--color-glow-strong),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
0 10px 28px -10px var(--color-glow-strong),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-secondary {
background: var(--color-muted);
background: var(--color-glass-strong);
color: var(--color-foreground);
border: 1px solid var(--color-border);
border: 1px solid var(--color-rule-strong);
}
.btn-secondary:hover:not(:disabled) {
opacity: 0.8;
background: var(--color-glass-elev);
border-color: var(--color-rule-strong);
}
.btn-danger {
background: var(--color-error-fg);
color: white;
border: 0;
font-weight: 600;
box-shadow: 0 6px 20px -8px color-mix(in srgb, var(--color-error-fg) 50%, transparent);
}
.btn-danger:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 10px 28px -10px color-mix(in srgb, var(--color-error-fg) 60%, transparent);
}
.btn-ghost {
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid transparent;
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-muted);
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
</style>
+46 -15
View File
@@ -84,23 +84,54 @@
}
}),
EditorView.lineWrapping,
EditorView.theme({
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
'.ͼc': { color: '#e879f9' },
'.ͼd': { color: '#38bdf8' },
'.ͼ5': { color: '#6b7280' },
'.cm-tooltip-autocomplete': {
border: '1px solid var(--color-border)',
borderRadius: '0.375rem',
fontSize: '12px',
},
}),
];
// Apply oneDark first so its syntax-token colors are kept,
// then override with our Aurora-aware theme so background,
// borders, and gutters match the rest of the design.
if (isDark) extensions.push(oneDark);
extensions.push(EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: 'var(--font-mono)',
backgroundColor: 'var(--color-input-bg) !important',
borderRadius: '14px',
border: '1px solid var(--color-rule-strong)',
color: 'var(--color-foreground)',
overflow: 'hidden',
},
'.cm-editor': { backgroundColor: 'transparent !important', borderRadius: '14px' },
'.cm-scroller': { backgroundColor: 'transparent !important' },
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '12px 14px', caretColor: 'var(--color-primary)' },
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--color-muted-foreground)',
borderRight: '1px solid var(--color-border)',
},
'.cm-activeLineGutter': { backgroundColor: 'var(--color-glass-strong)' },
'.cm-activeLine': { backgroundColor: 'var(--color-glass-strong)' },
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'var(--color-glass-elev) !important' },
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--color-glow) !important' },
'.cm-focused': { outline: 'none' },
'&.cm-focused': { borderColor: 'var(--color-primary)', boxShadow: '0 0 0 3px var(--color-glow)' },
'.cm-error-line': { backgroundColor: 'rgba(255, 138, 120, 0.18)', outline: '1px solid rgba(255, 138, 120, 0.4)' },
'.ͼc': { color: 'var(--color-orchid)' },
'.ͼd': { color: 'var(--color-sky)' },
'.ͼ5': { color: 'var(--color-muted-foreground)' },
'.cm-tooltip-autocomplete': {
background: 'color-mix(in srgb, var(--color-background) 92%, transparent)',
backdropFilter: 'blur(28px) saturate(160%)',
border: '1px solid var(--color-rule-strong)',
borderRadius: '12px',
fontSize: '12px',
boxShadow: '0 12px 30px -12px rgba(0,0,0,0.4)',
overflow: 'hidden',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-glass-elev)',
color: 'var(--color-primary)',
},
}));
if (placeholder) extensions.push(cmPlaceholder(placeholder));
return extensions;
}
+30 -1
View File
@@ -231,7 +231,10 @@
"cleared": "Payload history cleared"
},
"notificationTracker": {
"title": "Notification Trackers",
"title": "Notification",
"titleEmphasis": "trackers",
"armed": "armed",
"paused": "paused",
"description": "Monitor albums for changes",
"newTracker": "New Tracker",
"cancel": "Cancel",
@@ -343,6 +346,11 @@
"albumDeleted": "Album deleted"
},
"targets": {
"titleEmphasis": "channel",
"titleEmphasisAll": "channels",
"receiver": "receiver",
"receivers": "receivers",
"channelsCount": "channels",
"title": "Targets",
"description": "Notification delivery destinations",
"descTelegram": "Telegram chat destinations for notifications",
@@ -411,6 +419,8 @@
"receiverDisabled": "Receiver disabled"
},
"users": {
"titleEmphasis": "& access",
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"addUser": "Add User",
@@ -428,6 +438,8 @@
"noUsers": "No users found"
},
"telegramBot": {
"titleEmphasis": "telegram",
"countLabel": "bots",
"title": "Telegram Bots",
"description": "Register and manage Telegram bots",
"addBot": "Add Bot",
@@ -504,6 +516,8 @@
"webhookFailed": "Failed to register webhook"
},
"trackingConfig": {
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Tracking Configs",
"description": "Define what events and assets to react to",
"newConfig": "New Config",
@@ -609,6 +623,8 @@
"nextDay": "next day"
},
"templateConfig": {
"titleEmphasis": "templates",
"countLabel": "templates",
"title": "Template Configs",
"description": "Define how notification messages are formatted",
"providerType": "Service Provider Type",
@@ -728,6 +744,7 @@
"album_shared": "Whether album is shared"
},
"settings": {
"titleEmphasis": "options",
"title": "Settings",
"description": "Global application settings",
"general": "General",
@@ -804,6 +821,8 @@
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
},
"matrixBot": {
"titleEmphasis": "matrix",
"countLabel": "bots",
"title": "Matrix Bots",
"description": "Matrix homeserver connections for room notifications",
"addBot": "Add Matrix Bot",
@@ -820,6 +839,8 @@
"operationFailed": "Operation failed"
},
"emailBot": {
"titleEmphasis": "email",
"countLabel": "accounts",
"title": "Email Bots",
"description": "SMTP email senders for notifications",
"addBot": "Add Email Bot",
@@ -839,6 +860,8 @@
"operationFailed": "Operation failed"
},
"cmdTemplateConfig": {
"titleEmphasis": "templates",
"countLabel": "templates",
"title": "Command Templates",
"description": "Customize command response messages with Jinja2 templates",
"newConfig": "New Config",
@@ -851,6 +874,8 @@
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
},
"commandConfig": {
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Command Configs",
"description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config",
@@ -873,6 +898,7 @@
"noTemplate": "Default (hardcoded)"
},
"commandTracker": {
"titleEmphasis": "trackers",
"title": "Command Trackers",
"description": "Manage command trackers and their listeners",
"newTracker": "New Tracker",
@@ -1155,6 +1181,8 @@
"close": "close"
},
"actions": {
"titleEmphasis": "automations",
"countLabel": "actions",
"title": "Actions",
"description": "Scheduled mutations on external services",
"addAction": "Add Action",
@@ -1212,6 +1240,7 @@
"triggerScheduled": "scheduled"
},
"backup": {
"titleEmphasis": "& restore",
"title": "Backup & Restore",
"description": "Export and import your configuration, or set up automatic backups",
"export": "Export Configuration",
+29
View File
@@ -231,6 +231,9 @@
"cleared": "История запросов очищена"
},
"notificationTracker": {
"titleEmphasis": "трекеры",
"armed": "активны",
"paused": "на паузе",
"title": "Трекеры уведомлений",
"description": "Отслеживание изменений в альбомах",
"newTracker": "Новый трекер",
@@ -343,6 +346,11 @@
"albumDeleted": "Альбом удалён"
},
"targets": {
"titleEmphasis": "канал",
"titleEmphasisAll": "каналы",
"receiver": "получатель",
"receivers": "получателей",
"channelsCount": "каналов",
"title": "Получатели",
"description": "Адреса доставки уведомлений",
"descTelegram": "Чаты Telegram для доставки уведомлений",
@@ -411,6 +419,8 @@
"receiverDisabled": "Получатель отключён"
},
"users": {
"titleEmphasis": "и доступ",
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"addUser": "Добавить пользователя",
@@ -428,6 +438,8 @@
"noUsers": "Пользователи не найдены"
},
"telegramBot": {
"titleEmphasis": "telegram",
"countLabel": "ботов",
"title": "Telegram боты",
"description": "Регистрация и управление Telegram ботами",
"addBot": "Добавить бота",
@@ -504,6 +516,8 @@
"webhookFailed": "Не удалось зарегистрировать webhook"
},
"trackingConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации отслеживания",
"description": "Определите, на какие события и файлы реагировать",
"newConfig": "Новая конфигурация",
@@ -609,6 +623,8 @@
"nextDay": "след. день"
},
"templateConfig": {
"titleEmphasis": "шаблоны",
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений",
"providerType": "Тип сервис-провайдера",
@@ -728,6 +744,7 @@
"album_shared": "Общий альбом"
},
"settings": {
"titleEmphasis": "параметры",
"title": "Настройки",
"description": "Глобальные настройки приложения",
"general": "Общие",
@@ -804,6 +821,8 @@
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
},
"matrixBot": {
"titleEmphasis": "matrix",
"countLabel": "ботов",
"title": "Matrix боты",
"description": "Подключения к Matrix серверам для уведомлений в комнаты",
"addBot": "Добавить Matrix бот",
@@ -820,6 +839,8 @@
"operationFailed": "Операция не удалась"
},
"emailBot": {
"titleEmphasis": "email",
"countLabel": "учётных записей",
"title": "Email боты",
"description": "SMTP отправители для уведомлений по email",
"addBot": "Добавить Email бот",
@@ -839,6 +860,8 @@
"operationFailed": "Операция не удалась"
},
"cmdTemplateConfig": {
"titleEmphasis": "шаблоны",
"countLabel": "шаблонов",
"title": "Шаблоны команд",
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
"newConfig": "Новый шаблон",
@@ -851,6 +874,8 @@
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
},
"commandConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
@@ -873,6 +898,7 @@
"noTemplate": "По умолчанию (встроенный)"
},
"commandTracker": {
"titleEmphasis": "трекеры",
"title": "Трекеры команд",
"description": "Управление трекерами команд и их слушателями",
"newTracker": "Новый трекер",
@@ -1155,6 +1181,8 @@
"close": "закрыть"
},
"actions": {
"titleEmphasis": "автоматизации",
"countLabel": "действий",
"title": "Действия",
"description": "Запланированные операции над внешними сервисами",
"addAction": "Добавить действие",
@@ -1212,6 +1240,7 @@
"triggerScheduled": "по расписанию"
},
"backup": {
"titleEmphasis": "и восстановление",
"title": "Резервное копирование",
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
"export": "Экспорт конфигурации",
@@ -0,0 +1,35 @@
/**
* Page-scoped primary action for the global topbar CTA.
*
* Each route declares its own primary action ("Add Provider",
* "New Tracker", etc.) by calling `topbarAction.set({...})`
* inside its `onMount`, and clears it on teardown. The layout
* reads `topbarAction.current` and renders the button.
*
* Falls back to the default "New tracker" CTA when no action is
* registered (set by the layout itself).
*/
export interface TopbarAction {
/** Visible label, e.g. "Add Provider". */
label: string;
/** Optional href — renders as <a>. Mutually exclusive with onclick. */
href?: string;
/** Optional click handler — renders as <button>. */
onclick?: () => void;
/** Optional MDI/NavIcon name for the leading glyph (default: mdiPlus). */
icon?: string;
}
let action = $state<TopbarAction | null>(null);
export const topbarAction = {
get current(): TopbarAction | null {
return action;
},
set(next: TopbarAction | null) {
action = next;
},
clear() {
action = null;
},
};
+1 -4
View File
@@ -21,6 +21,7 @@
matrixBotsCache, targetsCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -622,10 +623,6 @@
title={t('common.language')} aria-label={t('common.language')}>
<span class="topbar-locale font-mono">{getLocale().toUpperCase()}</span>
</button>
<a href="/notification-trackers" class="topbar-cta">
<NavIcon name="mdiPlus" size={14} />
<span>{t('dashboard.newTracker')}</span>
</a>
</div>
</div>
+19 -1
View File
@@ -68,6 +68,16 @@
})());
onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'citrus' }> = [];
const enabled = actions.filter((a: Action) => a.enabled).length;
const disabled = actions.length - enabled;
if (enabled > 0) pills.push({ label: `${enabled} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (disabled > 0) pills.push({ label: `${disabled} ${t('notificationTracker.paused')}`, tone: 'citrus' });
return pills;
});
async function load() {
try {
await Promise.all([
@@ -171,7 +181,15 @@
}
</script>
<PageHeader title={t('actions.title')} description={t('actions.description')}>
<PageHeader
title={t('actions.title')}
emphasis={t('actions.titleEmphasis')}
description={t('actions.description')}
crumb="Routing · Automation"
count={actions.length}
countLabel={t('actions.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('actions.addAction')}
</Button>
+8 -1
View File
@@ -86,7 +86,14 @@
}
</script>
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
<PageHeader
title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')}
crumb="Operators · Bots"
count={emailBots.length}
countLabel={t('emailBot.countLabel')}
>
<Button size="sm" onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}>
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</Button>
+8 -1
View File
@@ -84,7 +84,14 @@
}
</script>
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
<PageHeader
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
<Button size="sm" onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}>
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
</Button>
@@ -285,7 +285,14 @@
}
</script>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
<PageHeader
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
</Button>
@@ -80,6 +80,14 @@
let hasCommands = $derived(providerCommands.length > 0);
onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
try {
await Promise.all([
@@ -161,7 +169,15 @@
}
</script>
<PageHeader title={t('commandConfig.title')} description={t('commandConfig.description')}>
<PageHeader
title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')}
crumb="Routing · Commands"
count={configs.length}
countLabel={t('commandConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('commandConfig.newConfig')}
</Button>
@@ -7,6 +7,7 @@
import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } 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 IconPicker from '$lib/components/IconPicker.svelte';
@@ -140,6 +141,13 @@
onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
try {
const [cfgs, caps, vars] = await Promise.all([
@@ -355,11 +363,18 @@
}
</script>
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.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">
<PageHeader
title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')}
</button>
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t } from '$lib/i18n';
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,7 +73,24 @@
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
onMount(load);
onMount(() => {
topbarAction.set({
label: t('commandTracker.newTracker'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'citrus' }> = [];
const armed = trackers.filter((tr: { enabled?: boolean }) => tr.enabled).length;
const paused = trackers.length - armed;
if (armed > 0) pills.push({ label: `${armed} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (paused > 0) pills.push({ label: `${paused} ${t('notificationTracker.paused')}`, tone: 'citrus' });
return pills;
});
async function load() {
try {
[allCmdTrackers] = await Promise.all([
@@ -226,7 +244,15 @@
}
</script>
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
<PageHeader
title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')}
crumb="Routing · Commands"
count={trackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
</Button>
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -126,7 +127,25 @@
return base;
});
onMount(load);
onMount(() => {
topbarAction.set({
label: t('notificationTracker.newTracker'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
const armed = notificationTrackers.filter(t => t.enabled).length;
const paused = notificationTrackers.length - armed;
if (armed > 0) pills.push({ label: `${armed} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (paused > 0) pills.push({ label: `${paused} ${t('notificationTracker.paused')}`, tone: 'citrus' });
const providerCount = new Set(notificationTrackers.map(t => t.provider_id)).size;
if (providerCount > 0) pills.push({ label: `${providerCount} ${providerCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
loadError = '';
@@ -416,7 +435,15 @@
}
</script>
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
<PageHeader
title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')}
crumb="Routing · Notification"
count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
</Button>
+10 -1
View File
@@ -19,6 +19,8 @@
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { onDestroy } from 'svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
@@ -68,7 +70,14 @@
return pills;
});
onMount(load);
onMount(() => {
topbarAction.set({
label: t('providers.addProvider'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
async function load() {
try {
await providersCache.fetch(true);
+6 -1
View File
@@ -93,7 +93,12 @@
}
</script>
<PageHeader title={t('settings.title')} description={t('settings.description')} />
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
{#if !loaded}
<Loading />
@@ -292,7 +292,12 @@
}
</script>
<PageHeader title={t('backup.title')} description={t('backup.description')} />
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
{#if !loaded}
<Loading />
+26 -4
View File
@@ -6,6 +6,7 @@
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';
@@ -165,6 +166,20 @@
// ── 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([
@@ -418,11 +433,18 @@
}
</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">
<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>
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
@@ -207,7 +208,22 @@
]},
]);
onMount(load);
onMount(() => {
topbarAction.set({
label: t('templateConfig.newConfig'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(allTemplateConfigs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
try {
[, varsRef] = await Promise.all([
@@ -379,7 +395,15 @@
}
</script>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
<PageHeader
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
count={allTemplateConfigs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
</Button>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
@@ -190,7 +191,21 @@
let form: Record<string, any> = $state(defaultForm());
let descriptor = $derived(getDescriptor(form.provider_type));
onMount(load);
onMount(() => {
topbarAction.set({
label: t('trackingConfig.newConfig'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
@@ -248,7 +263,15 @@
}
</script>
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
<PageHeader
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
</Button>
+8 -1
View File
@@ -89,7 +89,14 @@
}
</script>
<PageHeader title={t('users.title')} description={t('users.description')}>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>