diff --git a/Dockerfile b/Dockerfile index 1903bc5..45afa13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,6 @@ EXPOSE 8420 USER appuser HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')" + CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"NOTIFY_BRIDGE_PORT\", 8420)}/api/health')" CMD ["notify-bridge"] diff --git a/README.md b/README.md index 574a80d..f8b5aca 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,49 @@ packages/ frontend/ — SvelteKit dashboard (Svelte 5, Tailwind CSS v4) ``` -## Quick Start +## Quick Docker Deploy + +```bash +docker run -d \ + --name notify-bridge \ + --restart unless-stopped \ + -p 8420:8420 \ + -v notify-bridge-data:/data \ + -e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \ + git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest +``` + +Then open `http://localhost:8420` in your browser. + +### Environment Variables + +| Variable | Required | Default | Description | +| -------- | -------- | ------- | ----------- | +| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) | +| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port | +| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins | +| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging | + +### Docker Compose + +```yaml +services: + notify-bridge: + image: git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest + container_name: notify-bridge + restart: unless-stopped + ports: + - "8420:8420" + volumes: + - notify-bridge-data:/data + environment: + - NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters + +volumes: + notify-bridge-data: +``` + +## Quick Start (Development) ```bash # Backend diff --git a/frontend/src/app.css b/frontend/src/app.css index 925eb5a..af791dc 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -12,7 +12,7 @@ --color-background: #f8f9fb; --color-foreground: #1a1a2e; --color-muted: #eef0f4; - --color-muted-foreground: #6b7280; + --color-muted-foreground: #525866; --color-border: #e2e4ea; --color-primary: #0d9488; --color-primary-foreground: #ffffff; @@ -34,6 +34,15 @@ --font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace; --radius: 0.625rem; + /* Layered z-index scale — refer to these instead of ad-hoc numbers. + Ordered: base < sticky < dropdown < overlay < modal < tooltip < toast */ + --z-base: 1; + --z-sticky: 10; + --z-dropdown: 30; + --z-overlay: 40; + --z-modal: 50; + --z-tooltip: 60; + --z-toast: 70; } /* Dark theme overrides */ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 14a4267..f0f0cba 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,13 @@ const API_BASE = '/api'; +/** Normalize a caught error to a user-safe message. */ +export function errMsg(err: unknown, fallback = 'Unexpected error'): string { + if (err instanceof Error && err.message) return err.message; + if (typeof err === 'string' && err) return err; + return fallback; +} + function getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('access_token'); diff --git a/frontend/src/lib/components/Hint.svelte b/frontend/src/lib/components/Hint.svelte index 1e27881..372fe09 100644 --- a/frontend/src/lib/components/Hint.svelte +++ b/frontend/src/lib/components/Hint.svelte @@ -21,20 +21,22 @@ {#if visible} -
+
{text}
{/if} diff --git a/frontend/src/lib/providers/types.ts b/frontend/src/lib/providers/types.ts index 599715d..4647192 100644 --- a/frontend/src/lib/providers/types.ts +++ b/frontend/src/lib/providers/types.ts @@ -40,7 +40,7 @@ export interface ConfigField { min?: number; max?: number; /** Default value for this field. */ - defaultValue?: string | number; + defaultValue?: string | number | boolean; } // ── Event tracking (TrackingConfig form) ───────────────────────────── @@ -60,14 +60,14 @@ export interface EventTrackingField { export interface ExtraTrackingField { key: string; label: string; - type: 'number' | 'grid-select'; + type: 'number' | 'grid-select' | 'toggle'; /** Grid-select item source function name from grid-items.ts. */ gridItems?: string; gridColumns?: number; hint?: string; min?: number; max?: number; - defaultValue?: string | number; + defaultValue?: string | number | boolean; } /** A feature section like periodic summary, scheduled assets, memory mode. */ diff --git a/frontend/src/lib/stores/caches.svelte.ts b/frontend/src/lib/stores/caches.svelte.ts index 340e4b4..1892040 100644 --- a/frontend/src/lib/stores/caches.svelte.ts +++ b/frontend/src/lib/stores/caches.svelte.ts @@ -6,6 +6,7 @@ */ import { createEntityCache } from './entity-cache.svelte'; +import { api } from '$lib/api'; import type { ServiceProvider, NotificationTarget, @@ -57,19 +58,56 @@ export const commandTrackersCache = createEntityCache('/command- /** Actions — used by Actions page. */ export const actionsCache = createEntityCache('/actions'); -/** Provider capabilities — used by Template Configs, Command Configs. */ +export interface SlotDef { + name: string; + description: string; + required?: boolean; +} + +export interface CommandDef { + name: string; + description?: string; +} + +export interface ActionTypeDef { + key: string; + name: string; + description?: string; + [key: string]: unknown; +} + +export interface ProviderCapabilities { + notification_slots?: SlotDef[]; + command_slots?: SlotDef[]; + commands?: CommandDef[]; + action_types?: ActionTypeDef[]; + [key: string]: unknown; +} + +export type CapabilitiesMap = Record; + +/** Provider capabilities — used by Template Configs, Command Configs. + * Dedups concurrent fetches so two fast navigations do not double-hit the API. */ export const capabilitiesCache = (() => { - let data = $state>({}); + let data = $state({}); let fetchedAt = $state(0); - const TTL = 60_000; // 1 minute + let inflight: Promise | null = null; + const TTL = 60_000; return { get items() { return data; }, - async fetch(force = false): Promise> { + async fetch(force = false): Promise { if (!force && Object.keys(data).length > 0 && Date.now() - fetchedAt < TTL) return data; - const { api } = await import('$lib/api'); - data = await api('/providers/capabilities'); - fetchedAt = Date.now(); - return data; + if (inflight) return inflight; + inflight = (async () => { + try { + data = await api('/providers/capabilities'); + fetchedAt = Date.now(); + return data; + } finally { + inflight = null; + } + })(); + return inflight; }, }; })(); @@ -78,15 +116,23 @@ export const capabilitiesCache = (() => { export const supportedLocalesCache = (() => { let data = $state(['en', 'ru']); let fetchedAt = $state(0); - const TTL = 300_000; // 5 minutes + let inflight: Promise | null = null; + const TTL = 300_000; return { get items() { return data; }, async fetch(force = false): Promise { if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data; - const { api } = await import('$lib/api'); - data = await api('/settings/locales'); - fetchedAt = Date.now(); - return data; + if (inflight) return inflight; + inflight = (async () => { + try { + data = await api('/settings/locales'); + fetchedAt = Date.now(); + return data; + } finally { + inflight = null; + } + })(); + return inflight; }, }; })(); diff --git a/frontend/src/routes/bots/EmailBotTab.svelte b/frontend/src/routes/bots/EmailBotTab.svelte index b9a8030..37de54d 100644 --- a/frontend/src/routes/bots/EmailBotTab.svelte +++ b/frontend/src/routes/bots/EmailBotTab.svelte @@ -21,7 +21,7 @@ let editingEmail = $state(null); let emailSubmitting = $state(false); let emailTesting = $state>({}); - let confirmDeleteEmail = $state(null); + let confirmDeleteEmail = $state<{ id: number; onconfirm: () => Promise } | null>(null); let error = $state(''); const defaultEmailForm = () => ({ diff --git a/frontend/src/routes/bots/MatrixBotTab.svelte b/frontend/src/routes/bots/MatrixBotTab.svelte index ec2b904..5e47bf7 100644 --- a/frontend/src/routes/bots/MatrixBotTab.svelte +++ b/frontend/src/routes/bots/MatrixBotTab.svelte @@ -21,7 +21,7 @@ let editingMatrix = $state(null); let matrixSubmitting = $state(false); let matrixTesting = $state>({}); - let confirmDeleteMatrix = $state(null); + let confirmDeleteMatrix = $state<{ id: number; onconfirm: () => Promise } | null>(null); let error = $state(''); const defaultMatrixForm = () => ({ diff --git a/frontend/src/routes/bots/TelegramBotTab.svelte b/frontend/src/routes/bots/TelegramBotTab.svelte index 4e87b39..8e5e602 100644 --- a/frontend/src/routes/bots/TelegramBotTab.svelte +++ b/frontend/src/routes/bots/TelegramBotTab.svelte @@ -375,12 +375,14 @@
copyChatId(e, chat.chat_id)} + onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }} title={t('telegramBot.clickToCopy')} + aria-label={t('telegramBot.clickToCopy')} role="button" tabindex="0"> {chat.title || chat.username || t('common.unknown')} {chatTypeLabel(chat.type)} {(chat.language_code || '—').toUpperCase()} -
e.stopPropagation()}> +
e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}> updateChatLanguage(bot.id, chat, String(val ?? ''))} />
-
e.stopPropagation()}> +
e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>