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:
+40
-8
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Последние запросы",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user