feat: production readiness — security, perf, bug fixes, bridge self-monitoring

Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.

## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
  event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
  acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
  featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession

## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
  cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch

## Database
- UNIQUE indexes on service_provider.webhook_token,
  telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
  telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
  partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
  delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified

## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
  identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
  on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events

## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
  oauth/client_secret/webhook_secret/csrf in both header filter and
  template extras filter

## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
  $effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
  now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
  goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
  Telegram bot toggles

## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
  (wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
  HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
  event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
  procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
  test_planka_parser (6), test_immich_change_detector (6),
  test_backup_roundtrip (1)

## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
  failures), bridge_self_deferred_backlog (pending count crosses
  threshold), bridge_self_target_failures (consecutive 5xx/network
  failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
  provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
  thresholds (logged only) — wire to your own Telegram/Email/Matrix to
  get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
  with backfill migration, frontend descriptor (excluded from "create
  provider" wizard since auto-managed)

Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
  receive failure alerts

Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 02:16:49 +03:00
parent 22127e2a59
commit 10d30fc956
97 changed files with 5423 additions and 821 deletions
+40 -8
View File
@@ -2,8 +2,41 @@
* API client with JWT auth for the Notify Bridge backend.
*/
import { goto } from '$app/navigation';
const API_BASE = '/api';
/**
* Thrown when the API client decides to redirect the user to /login (after a
* terminal 401). Caller-side `try/catch` blocks can branch on
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
* — the redirect itself is the user-visible signal.
*/
export class AuthRedirectError extends Error {
constructor() {
super('Unauthorized — redirecting to login');
this.name = 'AuthRedirectError';
}
}
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
// the dashboard's parallel cache loads) should only schedule a single
// `goto('/login')` instead of stacking N navigations.
let _redirecting = false;
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
function redirectToLogin(): void {
if (_redirecting) return;
_redirecting = true;
clearTokens();
if (typeof window !== 'undefined') {
// SvelteKit's goto() with replaceState avoids leaving the failed page
// in the back-stack (no "back-button to broken view" UX). We don't
// reset `_redirecting` — the page about to unmount makes it moot.
goto('/login', { replaceState: true });
}
}
/** 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;
@@ -129,11 +162,11 @@ export async function api<T = any>(
}
if (res.status === 401 && token) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
redirectToLogin();
// Tagged so the caller's catch can distinguish "we already showed
// the user a redirect" from a real authorization failure they
// should snackbar.
throw new AuthRedirectError();
}
if (res.status === 204) return undefined as T;
@@ -204,9 +237,8 @@ export async function fetchAuth(
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
redirectToLogin();
throw new AuthRedirectError();
}
if (!res.ok) {
@@ -240,34 +240,42 @@
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<!-- Action buttons — deep-link + highlight the related entity card.
IDs are snapshotted into local consts so the deferred onclick
closures don't lose the narrowed type that the `{#if ...}` gate
proves at template-render time. -->
<div class="actions">
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
{@const providerId = displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', providerId)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
{@const botId = displayEvent.telegram_bot_id}
<button type="button" onclick={() => openEntity('/bots', botId)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
{@const cmdTrackerId = displayEvent.command_tracker_id}
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
{@const actionId = displayEvent.action_id}
<button type="button" onclick={() => openEntity('/actions', actionId)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
{@const trackerId = displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
@@ -17,6 +17,7 @@
columns = 2,
disabled = false,
compact = false,
onChange,
}: {
items: GridItem[];
value: string | number | null;
@@ -24,6 +25,13 @@
columns?: number;
disabled?: boolean;
compact?: boolean;
/**
* Optional one-way change callback. Fired in addition to updating
* `value` so callers that own state externally (e.g. a global store)
* can avoid the read-modify-write feedback loop that `bind:value` plus
* a sync `$effect` produces.
*/
onChange?: (value: string | number) => void;
} = $props();
let open = $state(false);
@@ -63,6 +71,7 @@
value = item.value;
open = false;
search = '';
onChange?.(item.value);
}
function handleKeydown(e: KeyboardEvent) {
+14 -1
View File
@@ -185,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
...allDescriptors().map(descriptorToGridItem),
];
/** Provider types the user is allowed to create from the "new provider" wizard.
*
* Excludes ``bridge_self`` because it's auto-created exactly once per user
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
* wizard would either duplicate the row or surface a confusing 409.
*/
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
allDescriptors()
.filter((d) => d.type !== 'bridge_self')
.map((d) => d.type);
/** Provider type selector (no "All" option). */
export const providerTypeItems = (): GridItem[] =>
allDescriptors().map(descriptorToGridItem);
allDescriptors()
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
.map(descriptorToGridItem);
+13 -1
View File
@@ -236,6 +236,13 @@
"typeGooglePhotos": "Google Photos",
"typeWebhook": "Generic Webhook",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Bridge Self-Monitoring",
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
"bridgeSelfTargetThreshold": "Target send failure threshold",
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
"haAccessToken": "Long-Lived Access Token",
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
@@ -663,6 +670,9 @@
"haServiceCalled": "Service called",
"haEventFired": "Other HA event (catch-all)",
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
"bridgeSelfPollFailures": "Tracker poll failures",
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
"bridgeSelfTargetFailures": "Target send failures",
"trackImages": "Track images",
"trackVideos": "Track videos",
"favoritesOnly": "Favorites only",
@@ -1199,6 +1209,7 @@
},
"common": {
"loading": "Loading...",
"auto": "Auto",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@@ -1365,7 +1376,8 @@
"providerNut": "Network UPS monitoring",
"providerGooglePhotos": "Google Photos albums & shared libraries",
"providerWebhook": "Receive events via HTTP POST",
"providerHomeAssistant": "Home Assistant event bus over WebSocket"
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
},
"webhookLogs": {
"title": "Recent Payloads",
+13 -1
View File
@@ -236,6 +236,13 @@
"typeGooglePhotos": "Google Фото",
"typeWebhook": "Универсальный вебхук",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Самомониторинг моста",
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
"haAccessToken": "Долгоживущий токен доступа",
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
@@ -663,6 +670,9 @@
"haServiceCalled": "Вызвана служба",
"haEventFired": "Прочее событие HA (catch-all)",
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
"bridgeSelfPollFailures": "Сбои опроса трекера",
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
"trackImages": "Фото",
"trackVideos": "Видео",
"favoritesOnly": "Только избранные",
@@ -1199,6 +1209,7 @@
},
"common": {
"loading": "Загрузка...",
"auto": "Авто",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
@@ -1365,7 +1376,8 @@
"providerNut": "Мониторинг ИБП через NUT",
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
"providerWebhook": "Приём событий через HTTP POST",
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket"
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
},
"webhookLogs": {
"title": "Последние запросы",
+98
View File
@@ -0,0 +1,98 @@
import type { ProviderDescriptor } from './types';
/**
* Bridge self-monitoring provider descriptor.
*
* The bridge_self provider has no remote URL and no credentials. The only
* configuration surface is the three thresholds below, used by the server
* to decide when an internal failure deserves a notification.
*
* Exactly one bridge_self provider exists per user, auto-seeded on user
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
*/
export const bridgeSelfDescriptor: ProviderDescriptor = {
type: 'bridge_self',
defaultName: 'Bridge Self-Monitoring',
icon: 'mdiAlertCircleOutline',
hasUrl: false,
configFields: [
{
key: 'poll_failure_threshold',
configKey: 'poll_failure_threshold',
label: 'providers.bridgeSelfPollThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 3,
hint: 'providers.bridgeSelfPollThresholdHint',
},
{
key: 'deferred_backlog_threshold',
configKey: 'deferred_backlog_threshold',
label: 'providers.bridgeSelfDeferredThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 100,
hint: 'providers.bridgeSelfDeferredThresholdHint',
},
{
key: 'target_failure_threshold',
configKey: 'target_failure_threshold',
label: 'providers.bridgeSelfTargetThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 5,
hint: 'providers.bridgeSelfTargetThresholdHint',
},
],
buildConfig(form) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return {
config: {
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
target_failure_threshold: toInt(form.target_failure_threshold, 5),
},
};
},
hasConfigChanged(form, existing) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return (
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
);
},
eventFields: [
{
key: 'track_bridge_self_poll_failures',
label: 'trackingConfig.bridgeSelfPollFailures',
default: true,
},
{
key: 'track_bridge_self_deferred_backlog',
label: 'trackingConfig.bridgeSelfDeferredBacklog',
default: true,
},
{
key: 'track_bridge_self_target_failures',
label: 'trackingConfig.bridgeSelfTargetFailures',
default: true,
},
],
collectionMeta: null,
webhookBased: false,
};
+11
View File
@@ -113,6 +113,17 @@ export const immichDescriptor: ProviderDescriptor = {
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
},
// Periodic summaries / scheduled picks / memories / quiet hours all live on
// the linked tracking & template configs — surface that connection on the
// tracker form so users don't need to read docs to find them.
featureDiscoveryHint: {
messageKey: 'notificationTracker.featureDiscovery',
ctas: [
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
],
},
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
if (newIds.length === 0) return { proceed: true };
+2
View File
@@ -14,6 +14,7 @@ import { nutDescriptor } from './nut';
import { googlePhotosDescriptor } from './google-photos';
import { webhookDescriptor } from './webhook';
import { homeAssistantDescriptor } from './home-assistant';
import { bridgeSelfDescriptor } from './bridge-self';
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['immich', immichDescriptor],
@@ -24,6 +25,7 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['google_photos', googlePhotosDescriptor],
['webhook', webhookDescriptor],
['home_assistant', homeAssistantDescriptor],
['bridge_self', bridgeSelfDescriptor],
]);
/** Look up a provider descriptor by type. Returns null for unknown types. */
+16
View File
@@ -196,6 +196,22 @@ export interface ProviderDescriptor {
/** Whether this provider stores incoming payload history for debugging. */
payloadHistory?: boolean;
// ── Tracker-form discovery hint ──
/**
* Optional info banner shown on the TrackerForm to point users at related
* configuration pages they would otherwise have to discover from docs.
*
* The hint is rendered as a single i18n message followed by zero or more
* call-to-action links. ``ctas[].href`` may include ``{tracking_config_id}``
* / ``{template_config_id}`` placeholders that the form substitutes from
* the tracker's currently selected default-config IDs (or omits the
* ``?edit=...`` query when the value is 0).
*/
featureDiscoveryHint?: {
messageKey: string;
ctas?: Array<{ href: string; labelKey: string; icon?: string }>;
};
// ── Provider-specific hooks ──
/**
* Called after collection selection changes (before save).
+19 -1
View File
@@ -16,8 +16,19 @@ const DEFAULT_TTL_MS = 30_000; // 30 seconds
export interface EntityCache<T extends { id: number }> {
/** Reactive list of cached entities. */
readonly items: T[];
/** True only during the very first fetch (no cached data yet). */
/**
* True only during the very first fetch — when there is no cached data
* to show yet. Background re-fetches keep `loading` false so consumers
* keep rendering the previous list and don't flash a spinner; observe
* `refreshing` instead if a subtle indicator is needed.
*/
readonly loading: boolean;
/**
* True during any non-first fetch (cached items already populated).
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
* shimmer/disabled state" (refreshing) without sharing one flag.
*/
readonly refreshing: boolean;
/** Timestamp of last successful fetch. */
readonly fetchedAt: number;
/** Fetch entities — returns cached data if fresh, else hits network. */
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
): EntityCache<T> {
let _items = $state<T[]>([]);
let _loading = $state(false);
let _refreshing = $state(false);
let _fetchedAt = $state(0);
function isFresh(): boolean {
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
const existing = inflightRequests.get(endpoint);
if (existing) return existing;
// First-load vs background-refresh state. We split these so consumers
// can keep the previous list visible during a re-fetch (refreshing)
// instead of flashing a spinner placeholder (loading).
const isFirstLoad = _fetchedAt === 0;
if (isFirstLoad) _loading = true;
else _refreshing = true;
const request = api<T[]>(endpoint)
.then((data) => {
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
})
.finally(() => {
_loading = false;
_refreshing = false;
inflightRequests.delete(endpoint);
});
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
return {
get items() { return _items; },
get loading() { return _loading; },
get refreshing() { return _refreshing; },
get fetchedAt() { return _fetchedAt; },
fetch,
invalidate,
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
loadFromStorage();
export const globalProviderFilter = {
get id() {
// If providers are loaded and the stored ID doesn't match any, auto-clear
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
globalProviderFilter.clear();
return null;
}
return _providerId;
},
/**
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
* reconciliation against `providersCache` is the responsibility of a
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
* because writing during read inside a `$state`-derived getter triggers
* Svelte 5's `state_unsafe_mutation` warning.
*/
get id() { return _providerId; },
get initialized() { return _initialized; },
set(id: number | null) {
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
this.set(null);
},
/**
* Drop the stored provider ID if it no longer matches any item in the
* providers cache. Safe to call from a `$effect` after the cache has been
* fetched. Returns true when reconciliation actually changed state, so the
* caller can short-circuit follow-up work.
*/
reconcileWithCache(): boolean {
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
this.clear();
return true;
}
return false;
},
/** The currently selected provider object (reactive). */
get provider() {
const id = this.id; // triggers stale-ID auto-clear
const id = _providerId;
if (id == null) return null;
return providersCache.items.find(p => p.id === id) ?? null;
},
+31 -24
View File
@@ -5,7 +5,7 @@
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api';
import { api, errMsg } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -46,30 +46,40 @@
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
]);
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
let _syncingFilter = false;
// One-way: the store is the source of truth, the filter widget displays it.
// IconGridSelect mutations route through `onChange` (see template) so we
// never need a paired `$effect` to mirror the local <-> store value, which
// previously required a `_syncingFilter` reentrancy flag.
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
// Reserve the provider-filter row from first paint until the cache resolves.
// Without this, the row appears mid-paint and pushes nav items down on every
// hard reload — the most visible "jump" the user reported.
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
// Sync filter value → store
// Reconcile a stale persisted provider ID against the freshly-loaded
// providers cache. Lives here (not in the store getter) because writing
// `_providerId` from a `$state`-derived getter triggers Svelte's
// `state_unsafe_mutation`. Runs once per cache refresh.
$effect(() => {
const v = providerFilterValue;
if (_syncingFilter) return;
globalProviderFilter.set(v === 0 ? null : v);
// Track `fetchedAt` so we re-run after the cache loads.
void providersCache.fetchedAt;
void providersCache.items.length;
globalProviderFilter.reconcileWithCache();
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
_syncingFilter = true;
providerFilterValue = 0;
_syncingFilter = false;
}
});
function setProviderFilter(v: string | number) {
const num = typeof v === 'number' ? v : Number(v);
globalProviderFilter.set(num === 0 ? null : num);
}
// Collapsed-rail filter cycles through providers via the same setter so the
// store stays the single write path.
function cycleProviderFilter() {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
setProviderFilter(ids[(idx + 1) % ids.length]);
}
let showPasswordForm = $state(false);
let redirecting = $state(false);
@@ -91,7 +101,7 @@
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
}
// Read persisted UI state synchronously so first paint already matches the
@@ -446,18 +456,14 @@
{#if showProviderFilter}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
providerFilterValue = ids[(idx + 1) % ids.length];
}}
<button onclick={cycleProviderFilter}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
{/if}
</div>
{/if}
@@ -595,6 +601,7 @@
<NavIcon name="mdiMagnify" size={20} />
</button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
aria-expanded={mobileMoreOpen}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<NavIcon name="mdiDotsHorizontal" size={20} />
@@ -609,7 +616,7 @@
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="space-y-3">
+6 -6
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -99,8 +99,8 @@
capabilitiesCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('actions.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('actions.loadError'));
} finally { loaded = true; highlightFromUrl(); }
}
@@ -138,7 +138,7 @@
}
showForm = false; editing = null; actionsCache.invalidate(); await load();
snackSuccess(t('actions.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -151,7 +151,7 @@
await api(`/actions/${id}`, { method: 'DELETE' });
actionsCache.invalidate(); await load();
snackSuccess(t('actions.deleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function executeAction(id: number, dryRun = false) {
@@ -165,7 +165,7 @@
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
snackSuccess(msg);
actionsCache.invalidate(); await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
executing = { ...executing, [id]: false };
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -47,7 +47,7 @@
loading = true;
try {
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
loading = false;
}
@@ -63,7 +63,7 @@
people = Array.isArray(p) ? p : [];
albums = Array.isArray(a) ? a : [];
} catch {
// People/album endpoints may not exist yet degrade gracefully
// People/album endpoints may not exist yet — degrade gracefully
}
}
@@ -79,7 +79,7 @@
resetNewRule();
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
submitting = false;
}
@@ -91,7 +91,7 @@
});
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function deleteRule(ruleId: number) {
@@ -99,7 +99,7 @@
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
await loadRules();
snackSuccess(t('actions.ruleDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleRule(rule: ActionRule) {
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,7 @@
emailBotsCache.fetch(true),
matrixBotsCache.fetch(true),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
</script>
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
@@ -89,7 +89,7 @@
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { emailSubmitting = false; }
}
@@ -99,10 +99,10 @@
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteEmail = null; }
}
@@ -115,7 +115,7 @@
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || t('emailBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -85,7 +85,7 @@
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { matrixSubmitting = false; }
}
@@ -95,10 +95,10 @@
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteMatrix = null; }
}
@@ -111,7 +111,7 @@
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || t('matrixBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
+29 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
@@ -105,7 +105,7 @@
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
finally { submitting = false; }
}
@@ -115,10 +115,10 @@
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -147,7 +147,7 @@
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
@@ -156,11 +156,15 @@
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
// `desc` is the only locale-sensitive field — language *names* are intentionally
// shown in their own language (English under EN, Русский under RU, …) so users
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
// for the no-override row is translated.
let LANG_ITEMS = $derived([
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
@@ -177,7 +181,7 @@
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
]);
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
@@ -189,7 +193,7 @@
c.id === chat.id ? { ...c, language_override: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleChatCommands(botId: number, chat: TelegramChat) {
@@ -202,7 +206,7 @@
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function loadListenerStatus(botId: number) {
@@ -232,7 +236,7 @@
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
),
};
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function syncCommands(botId: number) {
@@ -241,7 +245,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -254,7 +258,7 @@
await loadWebhookStatus(botId);
}
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -274,7 +278,7 @@
} else {
snackError(res.error || t('telegramBot.webhookFailed'));
}
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -284,7 +288,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -315,7 +319,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatTesting = { ...chatTesting, [key]: false };
}
@@ -464,6 +468,10 @@
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
type="button"
role="switch"
aria-checked={chat.commands_enabled}
aria-label={t('telegramBot.commandsToggle')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
@@ -511,6 +519,10 @@
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
</div>
<button
type="button"
role="switch"
aria-checked={trk.enabled}
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => toggleListenerEnabled(bot.id, trk)}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -51,7 +51,7 @@
let submitting = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Immich command icons used as fallback when capabilities don't specify icons
// Immich command icons — used as fallback when capabilities don't specify icons
const commandIcons: Record<string, string> = {
help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple',
events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus',
@@ -105,7 +105,7 @@
commandTemplateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -214,7 +214,7 @@
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -227,10 +227,10 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
snackError(errMsg(err));
}
finally { confirmDelete = null; }
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
@@ -85,7 +85,7 @@
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
};
}));
$effect(() => {
@@ -144,10 +144,10 @@
* Group command slots by purpose so the form mirrors how notification
* templates are split (event vs scheduled vs settings).
*
* commandResponses primary reply templates (/start, /help, /status, data slots)
* commandErrors fallback messages (rate_limited, no_results)
* commandDescriptions desc_* slots: short menu blurbs in Telegram's command picker
* commandUsage usage_* slots: invocation examples shown by /help
* commandResponses — primary reply templates (/start, /help, /status, data slots)
* commandErrors — fallback messages (rate_limited, no_results)
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
* commandUsage — usage_* slots: invocation examples shown by /help
*/
let commandSlotGroups = $derived([
{
@@ -213,8 +213,8 @@
allCmdTplConfigs = cfgs;
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
} catch (err: unknown) {
error = errMsg(err, t('common.loadError'));
snackError(error);
} finally {
loaded = true;
@@ -354,9 +354,10 @@
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
}
}
@@ -407,8 +408,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -444,11 +445,12 @@
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
} finally {
confirmDelete = null;
}
@@ -546,7 +548,7 @@
{#each filteredSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name} {slot.description}"
description="/{slot.name} — {slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
@@ -585,9 +587,9 @@
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);"> {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ  {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} 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';
@@ -107,7 +107,7 @@
providersCache.fetch(), commandConfigsCache.fetch(),
telegramBotsCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -168,7 +168,7 @@
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -180,7 +180,7 @@
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandTrackerDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { confirmDelete = null; }
}
};
@@ -193,7 +193,7 @@
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
toggling = { ...toggling, [trk.id]: false };
}
@@ -226,7 +226,7 @@
snackSuccess(t('snack.listenerAdded'));
await loadListeners(trkId);
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingListener = { ...addingListener, [trkId]: false };
}
@@ -235,7 +235,7 @@
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
snackSuccess(t('snack.listenerRemoved'));
await loadListeners(trkId);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-listener album scope editing
@@ -264,7 +264,7 @@
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
function providerName(id: number): string {
@@ -398,7 +398,7 @@
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '' : ''}
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
</div>
@@ -473,7 +473,7 @@
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<span aria-hidden="true">В·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { login } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -37,7 +37,7 @@
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup');
} catch {
// The backend is unreachable surface that distinctly so the user
// The backend is unreachable — surface that distinctly so the user
// doesn't blame the login form for a network/backend problem.
backendDown = true;
}
@@ -50,8 +50,8 @@
try {
await login(username, password);
window.location.href = '/';
} catch (err: any) {
error = err.message || t('auth.loginFailed');
} catch (err: unknown) {
error = errMsg(err, t('auth.loginFailed'));
}
submitting = false;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api';
import { api, parseDate , errMsg} 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';
@@ -98,7 +98,7 @@
// Test types: basic is always available; periodic/scheduled/memory only for providers
// that have those notification slots in their capabilities AND have the feature
// enabled on the tracker's default TrackingConfig. A disabled feature on the
// default config means cron dispatch won't fire it in production either so
// default config means cron dispatch won't fire it in production either — so
// the test button would just surface a silent skip.
const allTestTypes: Record<string, {
key: string; icon: string; labelKey: string;
@@ -128,7 +128,7 @@
base.push({
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
// When surfaced, the button still renders but is disabled and
// shows *why* users who land here via the test menu without
// shows *why* — users who land here via the test menu without
// having toggled the feature on Tracking Config see a clear
// pointer to the missing setting instead of a silent failure.
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
@@ -166,8 +166,8 @@
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) {
loadError = err.message || t('common.loadFailed');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadFailed'));
snackError(loadError);
} finally { loaded = true; highlightFromUrl(); }
}
@@ -179,7 +179,7 @@
async function loadUsers() {
if (!form.provider_id) { users = []; return; }
// Skip the fetch when the descriptor has no user filters saves a
// Skip the fetch when the descriptor has no user filters — saves a
// pointless round-trip for providers like Immich/Scheduler.
const desc = getDescriptor(selectedProviderType);
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
@@ -289,7 +289,7 @@
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
}
async function autoCreateLinks() {
@@ -301,8 +301,8 @@
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
} catch (err: unknown) {
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
}
}
}
@@ -324,7 +324,7 @@
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
@@ -335,7 +335,7 @@
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
confirmDelete = null;
}
@@ -383,7 +383,7 @@
function trackerTiles(tracker: Tracker): MetaTile[] {
const tiles: MetaTile[] = [];
const trkDesc = getDescriptor(getProviderType(tracker));
// Status armed/paused with color tone
// Status — armed/paused with color tone
tiles.push(tracker.enabled
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
@@ -393,7 +393,7 @@
label: getProviderName(tracker.provider_id),
tone: 'lavender',
});
// Collections count + label (varies per provider descriptor)
// Collections — count + label (varies per provider descriptor)
const collCount = (tracker.collection_ids || []).length;
if (collCount > 0 || !trkDesc?.webhookBased) {
tiles.push({
@@ -403,7 +403,7 @@
tone: 'sky',
});
}
// Scan interval only meaningful for polling trackers
// Scan interval — only meaningful for polling trackers
if (!trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiTimerOutline',
@@ -451,7 +451,7 @@
newLinkTemplateConfigId[trackerId] = 0;
await load();
snackSuccess(t('snack.targetLinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingTarget = { ...addingTarget, [trackerId]: false };
}
@@ -460,7 +460,7 @@
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
@@ -470,7 +470,7 @@
body: JSON.stringify({ [field]: value }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
@@ -492,8 +492,8 @@
} else {
snackSuccess(t('snack.targetTestSent'));
}
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
@@ -593,8 +593,8 @@
<div class="list-row__secondary mt-0.5">
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} Р’В·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s Р’В·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
</div>
@@ -605,7 +605,7 @@
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '' : ''}
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? 'в–І' : 'в–ј'}
</button>
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -23,7 +23,7 @@
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place the
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
@@ -39,8 +39,8 @@
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} catch (err: unknown) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
} finally {
replacing = { ...replacing, [album.id]: false };
}
@@ -59,9 +59,36 @@
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(descriptor?.webhookBased ?? false);
let colMeta = $derived(descriptor?.collectionMeta);
// Providers without a collection (currently just the scheduler) drive the
// scheduler-specific form layout. Reading from the descriptor keeps the
// branch out of the component and lets a future provider opt in by
// declaring `collectionMeta: null`.
let isScheduler = $derived(colMeta == null);
let isWebhook = $derived(descriptor?.webhookBased ?? false);
/**
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
* descriptor-declared CTA href. When the corresponding form field is unset
* (value 0), strip the entire `?edit=...` query so the link still goes to
* the list page. Centralising this here avoids per-provider href logic
* leaking back into the template.
*/
function resolveHintHref(href: string): string {
const tcId = form.default_tracking_config_id;
const tplId = form.default_template_config_id;
if (href.includes('{tracking_config_id}')) {
return tcId
? href.replace('{tracking_config_id}', String(tcId))
: href.split('?')[0];
}
if (href.includes('{template_config_id}')) {
return tplId
? href.replace('{template_config_id}', String(tplId))
: href.split('?')[0];
}
return href;
}
// Custom variable management for scheduler
function addVariable() {
@@ -231,29 +258,27 @@
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
live on the tracking config, not on the tracker itself. The hint
content (message + CTAs) is declared on the provider descriptor so
each provider can surface its own discoverability links without
embedding `if (type === 'xyz')` here. -->
{#if descriptor?.featureDiscoveryHint}
{@const hint = descriptor.featureDiscoveryHint}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
{#if hint.ctas && hint.ctas.length > 0}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
{#each hint.ctas as cta}
<a href={resolveHintHref(cta.href)}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
{t(cta.labelKey)}
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
+10 -10
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -61,7 +61,7 @@
const tiles: MetaTile[] = [];
const h = health[provider.id];
const provDesc = getDescriptor(provider.type);
// Status first tile, color-coded
// Status — first tile, color-coded
if (h === true) {
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
} else if (h === false) {
@@ -107,10 +107,10 @@
try {
const u = new URL(url);
const segments = u.pathname.split('/').filter(Boolean);
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/' : ''}` : '';
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
return `${u.host}${tail}`;
} catch {
return url.length > 32 ? `${url.slice(0, 30)}` : url;
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
}
}
@@ -142,7 +142,7 @@
let health = $state<Record<number, boolean | null>>({});
// Status pill row for the page header derived from health probes.
// Status pill row for the page header — derived from health probes.
const headerPills = $derived.by(() => {
const onlineCount = Object.values(health).filter(v => v === true).length;
const offlineCount = Object.values(health).filter(v => v === false).length;
@@ -169,8 +169,8 @@
try {
await providersCache.fetch(true);
loadError = '';
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('providers.loadError'));
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
@@ -237,7 +237,7 @@
}
showForm = false; editing = null; providersCache.invalidate(); await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -248,10 +248,10 @@
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
}
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { api, fetchAuth , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -97,9 +97,10 @@
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
loaded = true;
}
@@ -110,7 +111,7 @@
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function applyAndRestart(): Promise<void> {
@@ -131,9 +132,9 @@
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
} catch (err: unknown) {
restartingOverlay = false;
snackError(err.message);
snackError(errMsg(err));
}
}
@@ -144,8 +145,8 @@
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
creatingBackup = false;
}
@@ -178,8 +179,8 @@
a.click();
URL.revokeObjectURL(url);
snackSuccess(t('backup.exportSuccess'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
exporting = false;
}
@@ -202,8 +203,8 @@
formData.append('file', importFile);
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
validating = false;
}
@@ -230,8 +231,8 @@
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
importing = false;
}
@@ -246,8 +247,8 @@
body: JSON.stringify(scheduledSettings),
});
snackSuccess(t('backup.scheduleSaved'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
savingSchedule = false;
}
@@ -258,8 +259,8 @@
loadingFiles = true;
try {
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
loadingFiles = false;
}
@@ -275,8 +276,8 @@
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -286,8 +287,8 @@
snackSuccess(t('backup.fileDeleted'));
confirmDeleteFile = '';
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
</script>
+2 -1
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { setup } from '$lib/auth.svelte';
import { errMsg } from '$lib/api';
import { t } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -25,7 +26,7 @@
try {
await setup(username, password);
window.location.href = '/';
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
submitting = false;
}
</script>
+33 -31
View File
@@ -3,7 +3,7 @@
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -26,7 +26,7 @@
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
// в”Ђв”Ђ Helpers в”Ђв”Ђ
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -74,7 +74,7 @@
return recv.receiver_key || '?';
}
// ── Constants ──
// в”Ђв”Ђ Constants в”Ђв”Ђ
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
@@ -97,7 +97,7 @@
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile useful when the "all types" filter is active and rows
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
@@ -115,8 +115,8 @@
tone: 'sky',
});
}
// Telegram targets expose a chat label in config surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram Р’В· @bot Р’В· Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
@@ -126,7 +126,7 @@
mono: true,
});
}
// Webhook target show host
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
@@ -142,7 +142,7 @@
return tiles;
}
// ── Derived state ──
// в”Ђв”Ђ Derived state в”Ђв”Ђ
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
@@ -158,7 +158,7 @@
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
// ── Target form state ──
// в”Ђв”Ђ Target form state в”Ђв”Ђ
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -204,7 +204,7 @@
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
// в”Ђв”Ђ Receiver inline form state в”Ђв”Ђ
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
@@ -228,7 +228,7 @@
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// в”Ђв”Ђ Effects в”Ђв”Ђ
// Reset form when switching target type tabs
$effect(() => {
@@ -239,11 +239,11 @@
addingReceiverForTarget = null;
});
// ── Data loading ──
// в”Ђв”Ђ Data loading в”Ђв”Ђ
onMount(load);
// ── Bot grouping ──
// в”Ђв”Ђ Bot grouping в”Ђв”Ђ
type TargetGroup = {
key: string;
@@ -355,8 +355,8 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('common.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadError'));
snackError(loadError);
} finally {
loaded = true;
@@ -372,7 +372,7 @@
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// Active discovery actually polls Telegram getUpdates and persists any new chats.
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
async function discoverReceiverBotChats(botId: number) {
if (!botId) return;
@@ -382,7 +382,7 @@
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
// в”Ђв”Ђ Target CRUD в”Ђв”Ђ
function openNew() {
form = defaultForm();
@@ -475,9 +475,10 @@
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
submitting = false;
}
@@ -488,7 +489,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -497,15 +498,16 @@
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
}
}
// ── Receiver CRUD ──
// в”Ђв”Ђ Receiver CRUD в”Ђв”Ђ
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
@@ -575,8 +577,8 @@
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
receiverSubmitting = false;
}
@@ -590,7 +592,7 @@
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function removeReceiver(targetId: number, receiverId: number) {
@@ -598,7 +600,7 @@
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
@@ -613,7 +615,7 @@
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testReceiver(targetId: number, receiverId: number) {
@@ -622,7 +624,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -82,7 +82,7 @@
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
@@ -261,7 +261,7 @@
capabilitiesCache.fetch(),
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
@@ -347,7 +347,7 @@
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
/**
@@ -404,8 +404,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -442,7 +442,7 @@
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
// Locale coverage count unique locales present across all slots
// Locale coverage — count unique locales present across all slots
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
@@ -472,10 +472,10 @@
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -624,9 +624,9 @@
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);"> {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ  {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -28,13 +28,13 @@
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
/** Grid-select item source lookup maps descriptor string name to actual function. */
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
};
/**
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" the only format cron
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
* dispatch accepts. Matched on blur for time-list fields; invalid values
* are surfaced inline next to the input.
*/
@@ -43,7 +43,7 @@
/** Per-field error messages surfaced inline under time-list inputs. */
let timeListErrors = $state<Record<string, string>>({});
/** Normalize "9:0 , 18:30" "09:00,18:30" on blur, clear error when valid. */
/** Normalize "9:0 , 18:30" в†’ "09:00,18:30" on blur, clear error when valid. */
function normalizeTimeList(key: string) {
const raw = String(form[key] ?? '').trim();
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
@@ -74,8 +74,8 @@
}
/**
* Quiet-hours preview: "22:00 07:00 next day (9h)" or "Quiet period is 0
* minutes adjust times" when start equals end. Handles overnight ranges
* Quiet-hours preview: "22:00 в†’ 07:00 next day (9h)" or "Quiet period is 0
* minutes — adjust times" when start equals end. Handles overnight ranges
* (start > end) correctly.
*/
function quietHoursPreview(start: string, end: string): string {
@@ -92,8 +92,8 @@
const m = span % 60;
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
const arrow = overnight
? `${start} ${end} ${t('trackingConfig.nextDay')}`
: `${start} ${end}`;
? `${start} в†’ ${end} ${t('trackingConfig.nextDay')}`
: `${start} в†’ ${end}`;
return `${arrow} (${dur})`;
}
@@ -112,12 +112,12 @@
/**
* Inline preview of the shipped default template for a scheduled/periodic/
* memory slot. Using the shipped default (not a tracker's current template)
* keeps this scoped to the tracking-config page which has no concept of
* keeps this scoped to the tracking-config page — which has no concept of
* which TemplateConfig a given tracker uses. Users who want to edit the
* actual config can click "Edit template" in the modal footer.
*
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
* this preview the user's UI locale (and other previews) are untouched.
* this preview — the user's UI locale (and other previews) are untouched.
*/
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
let previewLoading = $state(false);
@@ -158,8 +158,8 @@
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} catch (err: unknown) {
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
} finally {
previewLoading = false;
}
@@ -217,7 +217,7 @@
});
async function load() {
try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
}
@@ -262,7 +262,7 @@
if (config.quiet_hours_start && config.quiet_hours_end) {
tiles.push({
icon: 'mdiWeatherNight',
label: `${config.quiet_hours_start}${config.quiet_hours_end}`,
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
hint: t('trackingConfig.quietHoursStart'),
tone: 'citrus',
mono: true,
@@ -285,7 +285,7 @@
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.trackingConfigSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -294,10 +294,10 @@
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -344,7 +344,7 @@
{/if}
</div>
<!-- Event tracking driven by descriptor -->
<!-- Event tracking — driven by descriptor -->
{#if descriptor}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
@@ -377,7 +377,7 @@
{/if}
</fieldset>
<!-- Feature sections (periodic, scheduled, memory) driven by descriptor -->
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
{#each descriptor.featureSections ?? [] as section (section.key)}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">
@@ -494,9 +494,9 @@
</div>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
{config.periodic_enabled ? ` Р’В· ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` Р’В· ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` Р’В· ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<MetaStrip tiles={trackingConfigTiles(config)} />
@@ -518,7 +518,7 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<Modal open={previewModal !== null}
title={previewModal ? `${t('trackingConfig.previewTemplate')} ${previewModal.slotName}` : ''}
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
onclose={() => previewModal = null}>
{#if previewModal}
{#if previewLocales.length > 1}
@@ -537,7 +537,7 @@
{t('trackingConfig.previewSampleNote')}
</p>
<!-- Keep the prior rendered/error box mounted while refetching on locale
switch just dim it. Unmounting and replacing with a small ""
switch — just dim it. Unmounting and replacing with a small "…"
placeholder caused a one-frame layout jump as the modal shrank and
then re-expanded. -->
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
@@ -550,7 +550,7 @@
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
</div>
{:else}
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);"></div>
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
{/if}
</div>
<div class="flex gap-2 justify-end mt-3">
+225 -227
View File
@@ -1,227 +1,225 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />