feat: comprehensive code review fixes + receivers-only architecture
Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore
Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout
Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test
Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links
i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* API client with JWT auth for the Notify Bridge backend.
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string) {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = doRefreshAccessToken().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
// Try token refresh on 401
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { visible = false, children }: { visible: boolean; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<!-- Animated gradient mesh background -->
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="auth-card-wrapper" class:visible>
|
||||
<div class="auth-card">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
:global(.auth-logo-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
:global(.auth-control-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.auth-control-btn:hover) {
|
||||
color: var(--color-foreground);
|
||||
box-shadow: 0 0 8px var(--color-glow);
|
||||
}
|
||||
|
||||
:global(.auth-label) {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
:global(.auth-input) {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
:global(.auth-input:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
:global(.auth-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
:global(.auth-submit) {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.auth-submit:hover:not(:disabled)) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
:global(.auth-submit:active:not(:disabled)) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
:global(.auth-submit:disabled) {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
@@ -24,12 +24,12 @@
|
||||
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
|
||||
];
|
||||
|
||||
function filtered(): string[] {
|
||||
const filtered = $derived.by(() => {
|
||||
const allIcons = getAllMdiNames();
|
||||
if (!search) return popular.filter(p => allIcons.includes(p));
|
||||
const q = search.toLowerCase();
|
||||
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleOpen() {
|
||||
if (!open && buttonEl) {
|
||||
@@ -81,7 +81,7 @@
|
||||
<button type="button" onclick={() => select('')}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
|
||||
title="No icon">✕</button>
|
||||
{#each filtered() as iconName}
|
||||
{#each filtered as iconName}
|
||||
<button type="button" onclick={() => select(iconName)}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
|
||||
title={iconName.replace('mdi', '')}>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiFileDocumentEdit' }) },
|
||||
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
|
||||
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiTarget' }) },
|
||||
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots',
|
||||
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
|
||||
mapFn: (e: any) => ({ detail: `@${e.bot_username || ''}`, icon: e.icon || 'mdiRobot' }) },
|
||||
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
|
||||
mapFn: (e: any) => ({ detail: e.email || '', icon: e.icon || 'mdiEmailOutline' }) },
|
||||
|
||||
@@ -79,24 +79,24 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
// --- Chat action (Telegram targets) ---
|
||||
|
||||
export const chatActionItems = (): GridItem[] => [
|
||||
{ value: '', icon: 'mdiMinus', label: 'None' },
|
||||
{ value: 'typing', icon: 'mdiKeyboard', label: 'Typing' },
|
||||
{ value: 'upload_photo', icon: 'mdiImagePlus', label: 'Upload Photo' },
|
||||
{ value: 'upload_video', icon: 'mdiVideoPlus', label: 'Upload Video' },
|
||||
{ value: 'upload_document', icon: 'mdiFileUpload', label: 'Upload Doc' },
|
||||
{ value: 'record_video', icon: 'mdiVideo', label: 'Record Video' },
|
||||
{ value: 'record_voice', icon: 'mdiMicrophone', label: 'Record Voice' },
|
||||
{ value: '', icon: 'mdiMinus', label: t('targets.chatActionNone') },
|
||||
{ value: 'typing', icon: 'mdiKeyboard', label: t('targets.chatActionTyping') },
|
||||
{ value: 'upload_photo', icon: 'mdiImagePlus', label: t('targets.chatActionUploadPhoto') },
|
||||
{ value: 'upload_video', icon: 'mdiVideoPlus', label: t('targets.chatActionUploadVideo') },
|
||||
{ value: 'upload_document', icon: 'mdiFileUpload', label: t('targets.chatActionUploadDoc') },
|
||||
{ value: 'record_video', icon: 'mdiVideo', label: t('targets.chatActionRecordVideo') },
|
||||
{ value: 'record_voice', icon: 'mdiMicrophone', label: t('targets.chatActionRecordVoice') },
|
||||
];
|
||||
|
||||
// --- Preview target type ---
|
||||
|
||||
export const previewTargetTypeItems = (): GridItem[] => [
|
||||
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram' },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook' },
|
||||
{ value: 'telegram', icon: 'mdiSend', label: t('targets.typeTelegram') },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook') },
|
||||
];
|
||||
|
||||
// --- Provider type ---
|
||||
|
||||
export const providerTypeItems = (): GridItem[] => [
|
||||
{ value: 'immich', icon: 'mdiCamera', label: 'Immich' },
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
|
||||
];
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"checking": "Checking...",
|
||||
"typeImmich": "Immich",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
@@ -228,6 +229,14 @@
|
||||
"sendLargeAsDocuments": "Send large photos as documents",
|
||||
"chatAction": "Chat action",
|
||||
"chatActionNone": "None (no action)",
|
||||
"chatActionTyping": "Typing",
|
||||
"chatActionUploadPhoto": "Upload Photo",
|
||||
"chatActionUploadVideo": "Upload Video",
|
||||
"chatActionUploadDoc": "Upload Doc",
|
||||
"chatActionRecordVideo": "Record Video",
|
||||
"chatActionRecordVoice": "Record Voice",
|
||||
"typeTelegram": "Telegram",
|
||||
"typeWebhook": "Webhook",
|
||||
"overrideUsername": "Override bot username",
|
||||
"ntfyServer": "ntfy Server URL",
|
||||
"ntfyTopic": "Topic",
|
||||
@@ -236,7 +245,16 @@
|
||||
"selectEmailBot": "Select Email Bot",
|
||||
"selectMatrixBot": "Select Matrix Bot",
|
||||
"recipientEmail": "Recipient Email",
|
||||
"matrixRoomId": "Room ID"
|
||||
"matrixRoomId": "Room ID",
|
||||
"receivers": "Receivers",
|
||||
"noReceivers": "No receivers yet",
|
||||
"addReceiver": "Add Receiver",
|
||||
"receiverAdded": "Receiver added",
|
||||
"receiverDeleted": "Receiver deleted",
|
||||
"receiverUpdated": "Receiver updated",
|
||||
"confirmDeleteReceiver": "Delete this receiver?",
|
||||
"receiverEnabled": "Receiver enabled",
|
||||
"receiverDisabled": "Receiver disabled"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@@ -702,6 +720,10 @@
|
||||
"line": "line",
|
||||
"add": "Add"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Page not found",
|
||||
"goHome": "Go home"
|
||||
},
|
||||
"searchPalette": {
|
||||
"placeholder": "Search entities...",
|
||||
"noResults": "No results found",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"checking": "Проверка...",
|
||||
"typeImmich": "Immich",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
@@ -228,6 +229,14 @@
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы",
|
||||
"chatAction": "Действие в чате",
|
||||
"chatActionNone": "Нет (без действия)",
|
||||
"chatActionTyping": "Печатает",
|
||||
"chatActionUploadPhoto": "Загрузка фото",
|
||||
"chatActionUploadVideo": "Загрузка видео",
|
||||
"chatActionUploadDoc": "Загрузка документа",
|
||||
"chatActionRecordVideo": "Запись видео",
|
||||
"chatActionRecordVoice": "Запись голоса",
|
||||
"typeTelegram": "Telegram",
|
||||
"typeWebhook": "Вебхук",
|
||||
"overrideUsername": "Переопределить имя бота",
|
||||
"ntfyServer": "URL сервера ntfy",
|
||||
"ntfyTopic": "Тема",
|
||||
@@ -236,7 +245,16 @@
|
||||
"selectEmailBot": "Выберите Email бот",
|
||||
"selectMatrixBot": "Выберите Matrix бот",
|
||||
"recipientEmail": "Email получателя",
|
||||
"matrixRoomId": "ID комнаты"
|
||||
"matrixRoomId": "ID комнаты",
|
||||
"receivers": "Получатели",
|
||||
"noReceivers": "Нет получателей",
|
||||
"addReceiver": "Добавить получателя",
|
||||
"receiverAdded": "Получатель добавлен",
|
||||
"receiverDeleted": "Получатель удалён",
|
||||
"receiverUpdated": "Получатель обновлён",
|
||||
"confirmDeleteReceiver": "Удалить этого получателя?",
|
||||
"receiverEnabled": "Получатель включён",
|
||||
"receiverDisabled": "Получатель отключён"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
@@ -702,6 +720,10 @@
|
||||
"line": "строка",
|
||||
"add": "Добавить"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Страница не найдена",
|
||||
"goHome": "На главную"
|
||||
},
|
||||
"searchPalette": {
|
||||
"placeholder": "Поиск объектов...",
|
||||
"noResults": "Ничего не найдено",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export type SnackType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Snack {
|
||||
id: number;
|
||||
type: SnackType;
|
||||
message: string;
|
||||
detail?: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUTS: Record<SnackType, number> = {
|
||||
success: 3000,
|
||||
info: 3000,
|
||||
warning: 4000,
|
||||
error: 5000,
|
||||
};
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
let nextId = 1;
|
||||
let snacks = $state<Snack[]>([]);
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function getSnacks(): Snack[] {
|
||||
return snacks;
|
||||
}
|
||||
|
||||
export function addSnack(
|
||||
type: SnackType,
|
||||
message: string,
|
||||
options?: { detail?: string; timeout?: number },
|
||||
): void {
|
||||
const id = nextId++;
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUTS[type];
|
||||
const snack: Snack = { id, type, message, detail: options?.detail, timeout };
|
||||
|
||||
snacks = [snack, ...snacks];
|
||||
|
||||
// Enforce max visible — single batch removal
|
||||
if (snacks.length > MAX_VISIBLE) {
|
||||
const overflow = snacks.slice(MAX_VISIBLE);
|
||||
for (const s of overflow) {
|
||||
const timer = timers.get(s.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(s.id);
|
||||
}
|
||||
}
|
||||
snacks = snacks.slice(0, MAX_VISIBLE);
|
||||
}
|
||||
|
||||
// Auto-dismiss
|
||||
if (timeout > 0) {
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => removeSnack(id), timeout),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSnack(id: number): void {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
snacks = snacks.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export function snackSuccess(message: string): void {
|
||||
addSnack('success', message);
|
||||
}
|
||||
|
||||
export function snackError(message: string, detail?: string): void {
|
||||
addSnack('error', message, { detail });
|
||||
}
|
||||
|
||||
export function snackInfo(message: string): void {
|
||||
addSnack('info', message);
|
||||
}
|
||||
|
||||
export function snackWarning(message: string): void {
|
||||
addSnack('warning', message);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Theme management with Svelte 5 runes.
|
||||
* Supports light, dark, and system preference.
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
let theme = $state<Theme>('system');
|
||||
let resolved = $state<'light' | 'dark'>('light');
|
||||
let listenerRegistered = false;
|
||||
|
||||
export function getTheme() {
|
||||
return {
|
||||
get current() { return theme; },
|
||||
get resolved() { return resolved; },
|
||||
get isDark() { return resolved === 'dark'; },
|
||||
};
|
||||
}
|
||||
|
||||
export function setTheme(newTheme: Theme) {
|
||||
theme = newTheme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||
theme = saved;
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
|
||||
// Listen for system preference changes (register only once)
|
||||
if (typeof window !== 'undefined' && !listenerRegistered) {
|
||||
listenerRegistered = true;
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (theme === 'system') applyTheme();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
@@ -81,6 +81,17 @@ export interface Tracker {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TargetReceiver {
|
||||
id: number;
|
||||
target_id: number;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
receiver_key: string;
|
||||
enabled: boolean;
|
||||
chat_name?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -88,6 +99,8 @@ export interface NotificationTarget {
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
chat_name?: string;
|
||||
receiver_count: number;
|
||||
receivers: TargetReceiver[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user