Compare commits

...

57 Commits

Author SHA1 Message Date
alexei.dolgolyov c43dc598a1 chore: release v0.6.2
Release / release (push) Successful in 1m39s
2026-04-27 14:29:44 +03:00
alexei.dolgolyov 1bfec521d8 fix(redesign): EntitySelect for language pickers + portal Timezone picker
- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- TimezoneSelector dropdown was being clipped by Card's overflow:hidden
  and backdrop-filter; portalled to <body> with an overlay backdrop and
  styled as a centered modal palette (same pattern as EntitySelect).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
2026-04-27 14:18:58 +03:00
alexei.dolgolyov b320090a56 chore: release v0.6.1
Release / release (push) Successful in 3m17s
2026-04-25 15:25:23 +03:00
alexei.dolgolyov cc8d961c33 fix(redesign): make Active Wires pipe visually prominent
Wire column was content-width (min 100px) so the line vanished between
two wide endpoint blocks. Bumped to minmax(220px, 1.6fr) so the pipe
takes ~60% more space than either side, thickened the line 2→3px,
faded both ends via color-mix transparency stops, added a soft
primary-glow halo plus a 1px specular sheen, and beefed up the count
badge with a rule-strong border / inset highlight / drop shadow so it
reads as a node on the wire. Stacks to a single column below 880px.
2026-04-25 15:23:30 +03:00
alexei.dolgolyov 9eb478fdc9 chore: release v0.6.0
Release / release (push) Successful in 1m25s
2026-04-25 14:54:16 +03:00
alexei.dolgolyov ef942b77cc feat(telegram): per-chat command localization + unified locale resolver
Two related Telegram changes:

1. Per-chat command localization. setMyCommands now accepts a scope
   (BotCommandScopeChat) and deleteMyCommands clears scoped bindings.
   Command registration runs three tiers: default → per-language
   (Telegram client language) → per-chat (UI override). Saving a
   chat's language_override or commands_enabled toggle pushes the
   binding to Telegram inline rather than waiting on the 30s
   debounced bot-wide sync.

2. Unified Telegram locale resolution. Three test paths (bot test_chat,
   target receiver test, target-level fan-out) used to disagree on
   locale priority — the target receiver test in particular only
   consulted receiver.locale and ignored the chat's language_override.
   Introduced pick_telegram_locale (pure) and
   resolve_telegram_chat_locale (async DB lookup) in services/notifier
   so all three paths share one priority order:

       receiver.locale → chat.language_override → chat.language_code → fallback

   Fan-out keeps batch-loading TelegramChat rows for efficiency, just
   runs them through the same priority function now.
2026-04-25 14:41:28 +03:00
alexei.dolgolyov 711f218622 fix(redesign): a11y, mobile, perf polish for production push
Comprehensive pre-production sweep across the Aurora redesign — drives
svelte-check to 0 errors / 0 warnings (was 61) without changing visual
intent. Highlights:

- Mobile: hero title shrinks at 480px, signal-list stacks timestamp
  under sentence below 640px, sidebar icon buttons bumped to 40x40
- Light theme: muted-foreground darkened to #3a3560 to clear WCAG AA
  on glass surfaces and the modal close button
- Perf: topbar backdrop-filter 28→14px, mobile-more sheet 28→12px to
  cut concurrent blur layers on mid-tier mobile
- a11y: prefers-reduced-motion mute for aurora drift / pulses /
  shimmer / stagger; aria-label on every icon-only button;
  aria-describedby on Hint; combobox/listbox/aria-activedescendant on
  SearchPalette; modal dialog tabindex; 47 label-without-control
  warnings across 14 form pages cleaned up via for=/id= or label→div
- Dashboard derived state split into topology- vs status-bound layers
  so polling no longer re-runs the full provider/wires computation
- Mobile bottom nav derived from baseNavEntries by key lookup so
  adding a top-level nav entry keeps the two trees in sync
- Bug: template-configs page now respects the global provider filter
  for both the count meter and the type pill (was reading the
  unfiltered cache)
- Misc: portal EventChart tooltip and switch its swatches to Aurora
  tokens; CollapsibleSlot warning state uses warning-fg/-bg tokens
  instead of #d97706; Hint z-index 99999→9999; element refs across
  Modal/EntitySelect/MultiEntitySelect/SearchPalette/IconGridSelect/
  Hint/targets converted to \$state for reactivity; 4 dead
  .topbar-cta selectors removed
2026-04-25 14:41:12 +03:00
alexei.dolgolyov 9eb76c1407 feat(redesign): collapsible dashboard sections + glass mobile-more sheet
Generalize dashboard panel expansion: Stream / On Watch / Pulse / Wires
each get a chevron toggle persisted in localStorage under
dashboard_section_state (migrates legacy dashboard_chart_visible). Drop
the redundant inner border on EventChart so it doesn't double-frame the
panel. Mobile "more" sheet becomes full-height with translucent glass
(rgba(...,0.72) + 28px blur) instead of nearly-solid.
2026-04-25 12:37:56 +03:00
alexei.dolgolyov d356e5a3ac fix(redesign): portal overlays + solid popup surfaces for legibility
Portal EntitySelect/MultiEntitySelect/Modal/Snackbar/EventChart/Hint to
<body> so they escape backdrop-filter ancestors. Replace translucent
glass on popups (IconPicker, IconGridSelect, SearchPalette, Snackbar)
with solid backgrounds and theme-aware light-mode override.
2026-04-25 11:41:25 +03:00
alexei.dolgolyov 9643fe519e feat(redesign): stack PageHeader meter top-right, button bottom-right
The hero's right column now spreads vertically — count + label pinned
at the top, action button(s) flushed to the bottom-right corner of
the card via margin-top: auto. Row uses align-items: stretch so the
side column reaches the full hero height regardless of how tall the
left column gets (long descriptions, many pills, etc.).

Single CSS change in the shared component, so it applies to every
page that uses PageHeader (all 14 of them) — no per-page edits.
2026-04-25 02:58:58 +03:00
alexei.dolgolyov d662b50925 feat(redesign): roll subpage hero across all pages + Aurora Button + JinjaEditor + pulse fix
Big batch — every secondary page now wears the same glass-card hero
that landed on Providers earlier:

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

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

Other changes in this commit:

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

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

Build clean: 0 errors, 61 pre-existing a11y warnings unchanged.
2026-04-25 02:52:01 +03:00
alexei.dolgolyov 9733e5c122 feat(redesign): subpage hero header + iconpicker portal + tighter gaps
Three threads bundled:

- PageHeader.svelte upgraded to a glass-card subpage hero matching
  the dashboard hero language, scaled down. New optional props (all
  backward-compatible — old callers keep working): emphasis (italic
  gradient word appended to title), crumb (uppercase mono kicker),
  count + countLabel (right-side mono meter), pills (status chips
  with tones: mint / sky / orchid / coral / citrus / primary).
- Providers page wired up first as the test surface: pulls live
  online/offline/checking counts from the existing health probe and
  shows a type-count pill. Locale keys added (en + ru) for the new
  copy ('Service / providers' wordmark, longer description).
- IconPicker dropdown was suffering the same backdrop-filter
  containing-block bug as IconGridSelect — repositioned popups
  rendered inside any glass form panel got clipped or floated to
  the bottom of the page. Now portals to <body> via the shared
  $lib/portal action and uses Aurora glass styling end-to-end
  (solid surface, gradient-active cell, glass-strong search input).
- Layout gaps tightened to match the mockup:
    * sidebar→content horizontal gap is now 18px flat (was 50px:
      the 18px shell-gap PLUS another 32px wrapper padding on each
      child of main). Dropped px-4/md:px-8 from the topbar wrapper
      and the per-page content wrapper — main's children sit flush
      at the column boundary.
    * topbar→content vertical gap reduced to 12px (was 16px / pt-4).

Build clean, 0 errors.
2026-04-25 02:15:34 +03:00
alexei.dolgolyov 46a4a6ee29 fix(redesign): align topbar horizontal padding with page content
Topbar wrapper used md:px-0 (edge-to-edge of main column) while the
page content wrapper used md:px-8 (32px side padding). Result was a
32px overshoot on each side of the topbar pill versus the hero, stat
cards, and panels below it.

Match the wrappers — topbar now uses md:px-8 too. Left/right edges of
the search bar pill line up with the hero/stat panels.
2026-04-25 02:00:49 +03:00
alexei.dolgolyov 1895c5e2d4 fix(redesign): brand snap, event sentences, palette glass, full width
Round-up of feedback:

- Brand: drop italic-gradient 'Bridge' wordmark + 'SERVICE
  NOTIFICATIONS' tag. Now plain 'Notify Bridge' bold sans + 'v0.5.2'
  mono — exactly what the Aurora mockup shows.
- Event rows: replaced [collection_name] [event_tag] [count_tag] with
  a sentence form — bold provider, muted verb, bold count, gradient
  italic-feeling collection. Matches the mockup's 'Immich added 14
  assets to «...»' pattern. Tracker → provider trail kept underneath.
- SearchPalette: re-skinned to Aurora glass — solid surface (was
  using the now-translucent --color-card token and was nearly
  invisible), backdrop blur, hairline section headers with
  letterspaced mono labels, gradient active row, glass-bordered
  result icons + detail pills.
- 'On watch' provider deck hidden when a global provider filter is
  active (deck would only show that one provider — redundant). Two-col
  grid collapses to single full-width column in that mode.
- Layout: dropped the max-w-7xl cap on the topbar and the page
  container. Pages now stretch to full available width on wide
  displays.
- Section labels in sidebar: dropped :global() wrapper since the
  element is in the same component. Added font-mono for tighter
  letterspacing match to the mockup.

Build clean.
2026-04-25 01:46:16 +03:00
alexei.dolgolyov 0105d9f0ec fix(redesign): portal IconGridSelect popup + snap navbar to mockup
Three issues addressed:

1. IconGridSelect popup was clipped/mispositioned because the .panel
   ancestor has backdrop-filter, which makes it the containing block
   for position:fixed descendants (CSS spec gotcha). Added a tiny
   portal action ($lib/portal) that re-parents the popup + backdrop
   to document.body, and refreshed the popup styling to be Aurora-
   native (solid surface, gradient active state, glass-strong search).

2. Always-visible top search bar: a sticky glass strip at the top of
   every page with the search trigger (⌘K), theme cycler, locale
   toggle, and a primary 'New tracker' gradient CTA — moved out of
   the sidebar to free up rail space and make search reachable from
   anywhere. The sidebar's inline search button is gone.

3. Navbar snapped to the mockup:
   - Sidebar footer redone as a glass user-card (avatar gradient +
     username + role + mint live chip + change-password / docs / logout
     row beneath a hairline).
   - Section labels above each nav group (Overview / Routing /
     Operators / System) with a hairline rule that extends to the
     edge — same rhythm as the dashboard mockup.
   - Active nav link keeps the gradient bar + glass-elev background
     + inset highlight + soft outer glow.

i18n: nav.section* keys (en + ru), dashboard.newTracker reused for
the topbar CTA. Build clean.
2026-04-25 01:36:14 +03:00
alexei.dolgolyov d3210fd5ea feat(redesign): project mockup richness onto live dashboard
Bringing the Aurora dashboard mockup to feature parity in the real app:

- NavIcon component: thin-stroke SVG icon set covering ~30 nav-tier
  glyphs (dashboard, server, target, radar, bots, console, email,
  matrix, webhook, slack, bullhorn, settings, theme, search, logout,
  chevrons, plus/arrow). Falls back to MdiIcon for unknown names so
  every existing usage keeps working.
- layout.svelte: sidebar nav swapped from MdiIcon to NavIcon — dropping
  the dense filled glyphs in favour of soft 1.6px outlines that match
  the mockup. Main container max-width bumped from 5xl (1024px) to
  7xl (1280px) so the dashboard finally gets to breathe.
- +page.svelte (dashboard): full rewrite of the markup to project
  every mockup section onto live data:
    * Editorial Hero — 'Tonight, everything is flowing.' with gradient
      italic emphasis, live/attention pill driven by failure count,
      and a big mono throughput meter (24h events, armed/total,
      providers, targets) on the right.
    * Stats — kept the 4-card grid, refined accent palette per card.
    * Two-column row — Signal stream (events with full routing trail:
      tracker → provider, gradient avatar tile per event-type) on the
      left, On-watch provider deck (per-provider activity bar fill +
      live/idle pulse, derived from recent_events) on the right.
    * Pulse panel wrapping the existing EventChart with the new title
      treatment.
    * Active wires — Sankey-style 'Tracker → Target' route summary
      derived from notificationTrackers.tracker_targets + targetsCache
      with event counts per route.
    * Compose band — gradient call-to-action strip with new-tracker CTA.
- i18n: en.json + ru.json get the full set of new dashboard keys
  (hero copy, panel titles, compose band, wires labels).

Build: 0 errors, 57 pre-existing warnings unchanged.
2026-04-25 01:27:08 +03:00
alexei.dolgolyov d9ef3c6cc3 feat(redesign): aurora foundation — tokens, glass sidebar, dashboard
Foundation pass on the Aurora redesign:

- app.css: full token swap to lavender/orchid/mint/sky pastel palette,
  vivid aurora gradient backdrop, frosted glass surface tokens, two
  themes (Aurora dark + Pearl light), shadow recipe for floating glass.
- fonts: add Geist Sans / Geist Mono / Newsreader, drop DM Sans usage
  (legacy fontsource entries kept in package.json until full migration).
- layout.svelte: sidebar becomes a floating glass rail with a conic-
  gradient brand orb, Newsreader italic 'Bridge' wordmark, soft glass
  hovers on nav links, gradient active indicator, gradient user avatar.
- Card.svelte: glass surface + inner highlight + soft hover lift.
- PageHeader.svelte: Newsreader serif display title.
- +page.svelte (dashboard): stat cards become glass with colored
  accent 'orb', event timeline gets soft glass rows + slide-on-hover.

Build clean (0 errors, pre-existing a11y warnings unchanged).
Other pages still inherit old chrome via shared tokens but will need
component-specific passes; tracked separately.
2026-04-25 01:11:56 +03:00
alexei.dolgolyov 1e357244e1 chore(design): add aurora redesign mockups + chooser
Three full-fidelity dashboard mockups (Bridge/Console, Aurora/Glass,
Bento/Modular) plus a chooser index and a tracker-detail page in
the chosen Aurora language. Self-contained HTML, no build needed.
2026-04-25 01:11:42 +03:00
alexei.dolgolyov 770c198ac3 chore: release v0.5.2
Release / release (push) Successful in 1m48s
2026-04-24 21:58:40 +03:00
alexei.dolgolyov ab621b6abc feat: wire tracking-config display filters + per-tracker adaptive polling
Display filters (Immich tracking config):
- favorites_only drops events with no favorited new assets, or filters
  added_assets to favorites only
- assets_order_by/assets_order sort the rendered list
  (date / name / rating / random / none)
- max_assets_to_show caps rendered+attached media (default 5 -> 10)
- include_tags strips people from event extras and tags from each asset
- include_asset_details strips city/country/state/lat/lon/is_favorite/
  rating/description; load-bearing fields (thumbhash, file_size,
  playback_size, cache keys) preserved
- New apply_tracking_display_filters helper in dispatch_helpers; wired
  into watcher, webhooks, scheduled/periodic/memory, and manual
  test-dispatch
- Targets sharing a TrackingConfig dispatch together; targets with
  different TCs each see their own shaped event

Adaptive polling:
- Replace NotificationTracker.batch_duration with adaptive_max_skip
- Per-tracker opt-in: NULL/0 disables back-off (every tick runs);
  positive N caps the skip factor at (N-1)-in-N after long idle
- Scheduler caches the cap in module state for the tick fast-path
- Migration adds the new column; API schemas/responses, frontend types,
  i18n, and the tracker form updated to match
2026-04-24 21:12:10 +03:00
alexei.dolgolyov 187b889c45 chore: release v0.5.1
Release / release (push) Successful in 1m49s
2026-04-24 19:21:39 +03:00
alexei.dolgolyov b61394f057 feat(immich): per-album scheduled/memory dispatch + template tooling
Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.

Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.

UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.

Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
  aiohttp.ClientSession was passed — broke test dispatch for periodic/
  scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
  language_code instead of using the raw ?locale query param, matching
  the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
  header and the first asset when the multi-album branch is taken.
2026-04-24 19:15:54 +03:00
alexei.dolgolyov be15463fd2 feat(telegram): add 'none' listener mode for bots
Introduce a third update_mode option alongside polling/webhook. 'none'
disables both polling and webhook delivery — useful when another instance
owns the listener or when the bot is send-only. Switching into 'none' now
unschedules polling and unregisters any active webhook so Telegram stops
delivering updates.

New bots default to 'none' (safer when multiple bridges share a token).
Existing bots upgraded from a pre-update_mode schema keep 'polling' so
their behavior is unchanged.
2026-04-24 15:15:25 +03:00
alexei.dolgolyov 461fb495d7 chore: release v0.5.0
Release / release (push) Successful in 1m10s
2026-04-24 14:16:34 +03:00
alexei.dolgolyov 309dec2b44 feat(immich): wire cron-fired scheduled/periodic/memory dispatch
The scheduled_enabled / scheduled_times (and the periodic / memory
counterparts) on TrackingConfig had been wired into the model, the
API, and the test-dispatch path — but no production scheduler ever
read them, so users saw the slot in the UI and only ever got fires
through "Test". This adds the missing cron jobs and the dispatch
fan-out, both keyed off the app-level IANA timezone.

* services/scheduled_dispatch.py — production fan-out reusing the
  test-path event builders, picking the slot template per kind, and
  writing an EventLog row per fire so the dashboard reflects it.
* services/scheduler.py — _load_immich_dispatch_jobs builds one
  CronTrigger per (tracker, kind, HH:MM) from the tracker's default
  TrackingConfig; reschedule_immich_dispatch_jobs rebuilds them all
  on any relevant CRUD or timezone change.
* tracker / link / tracking-config CRUD endpoints now invalidate.

Also: skip dispatch when scheduled/memory yield zero matching assets
(prevents header-only "On this day:" spam), and update the EN/RU
default scheduled_assets templates to surface that the delivery is
a scheduled random selection.
2026-04-24 12:49:47 +03:00
alexei.dolgolyov 90def11b8d chore: release v0.4.0
Release / release (push) Successful in 1m3s
2026-04-23 21:12:17 +03:00
alexei.dolgolyov 8f0346ea03 fix(csp): allow unsafe-inline scripts for SvelteKit hydration bootstrap
The static-adapter build emits an inline <script> with the hydration
payload; ``script-src 'self'`` alone blocks the SPA from starting
(browser error: "Executing inline script violates the following Content
Security Policy directive").

Mirrors the 'unsafe-inline' already present for style-src. Primary XSS
protection still comes from Svelte's auto-escaping and
frontend/src/lib/sanitize.ts for the {@html} paths that render user
content. CSP still blocks remote scripts (no https: in script-src),
framing (frame-ancestors 'none'), base-uri hijacking, and form
exfiltration.
2026-04-23 21:08:17 +03:00
alexei.dolgolyov a6a854ad21 perf(docker): install uv from PyPI instead of ghcr.io (avoid slow GHCR pulls)
Release / release (push) Successful in 1m46s
2026-04-23 21:00:04 +03:00
alexei.dolgolyov 19036a90bb ci: drop trivy scan from release (never failed, output discarded)
Release / release (push) Successful in 31s
2026-04-23 20:55:30 +03:00
alexei.dolgolyov 592e1b6114 perf(docker): split external deps into a cacheable layer, swap pip for uv
Stage 3's `pip install /tmp/wheels/*.whl` re-resolved and re-downloaded
~50 transitive deps on every build, because the wheel filenames (and
thus the layer hash) changed on every version bump.

Two changes:
  1. Emit /wheels/deps.txt in the build stage — external deps only,
     notify-bridge-* siblings filtered out. The runtime stage installs
     from this file first, so the cache key is the pyproject.toml deps
     (stable across releases) instead of the local wheel filenames
     (changes every release). Registry buildcache now serves the whole
     install layer on version-only bumps.

  2. Swap pip for uv (ghcr.io/astral-sh/uv:0.11.7). uv resolves and
     downloads ~10x faster than pip; combined with a BuildKit cache
     mount on /root/.cache/uv and UV_COMPILE_BYTECODE=1, a cold install
     drops from ~60-90s to a few seconds.

Local wheels are now installed with --no-deps since externals are
already satisfied.
2026-04-23 20:52:36 +03:00
alexei.dolgolyov bbcdf1c5d1 ci: skip build.yml on release commits 2026-04-23 20:46:09 +03:00
alexei.dolgolyov f9040370bc ci: drop backend pytest stage (too slow on hosted runner)
Build and Test / build-image (push) Has been cancelled
Build and Test / test-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
The editable install of core+server+dev pulls the full scientific Python
stack (SQLAlchemy, aiohttp, pytest, httpx, slowapi, uvicorn[standard],
apscheduler and their transitives) on every CI run. Even with pip cache
the restore + install takes minutes per job — not worth it for a suite
that still runs locally via ``pytest packages/server/tests``.

Kept the frontend svelte-check + build and the non-push Docker image
build. Release workflow no longer has a test gate either (same reason).
Bring the test stage back once we have a prebuilt CI image with deps.
2026-04-23 20:40:53 +03:00
alexei.dolgolyov 3b683ce82c ci: cache pip downloads and collapse install into one pip call
Build and Test / build-image (push) Has been cancelled
Build and Test / test-backend (push) Has been cancelled
Build and Test / test-frontend (push) Has been cancelled
Two wins:
  * actions/setup-python's built-in pip cache, keyed on the two
    pyproject.toml files, turns the 20+ transitive dep downloads into
    a single tarball restore on cache hit.
  * One ``pip install -e ./core -e ./server[dev]`` call instead of
    two — lets pip's resolver run once over the combined graph and
    skips the second invocation's overhead.

Also dropped ``pip install --upgrade pip``: the runner image already
ships a recent pip, and the upgrade ran once per CI job for no gain.
2026-04-23 20:40:17 +03:00
alexei.dolgolyov 2bec25353b ci: install editable packages inside a venv
Build and Test / test-frontend (push) Successful in 9m46s
Release / release (push) Has been cancelled
Release / test (push) Has been cancelled
Build and Test / build-image (push) Has been cancelled
Build and Test / test-backend (push) Has been cancelled
The hosted Gitea runner image pre-installs older versions of both packages
in its system Python site-packages and retains stale ~otify_bridge_core /
~otify_bridge_server dist-info directories from prior interrupted runs.
``pip install -e`` against the system interpreter tries to uninstall those,
the rollback fires mid-transaction, and the runner's
``/opt/hostedtoolcache/.../bin/notify-bridge`` console script disappears
before the new install can be placed:

  ERROR: Could not install packages due to an OSError:
  [Errno 2] No such file or directory:
  '/opt/hostedtoolcache/Python/3.12.12/x64/bin/notify-bridge'

Installing into a fresh venv sidesteps the pre-cached state entirely (and
is the recommendation pip itself prints on every run).
2026-04-23 20:23:42 +03:00
alexei.dolgolyov e44d387c7f chore: release v0.4.0
Build and Test / test-backend (push) Failing after 1m6s
Release / test (push) Failing after 1m7s
Release / release (push) Has been skipped
Build and Test / test-frontend (push) Successful in 9m54s
Build and Test / build-image (push) Has been skipped
2026-04-23 20:18:34 +03:00
alexei.dolgolyov 7cbb02b1ef feat(db): pre-migration SQLite snapshots via VACUUM INTO
Build and Test / test-backend (push) Successful in 2m38s
Build and Test / test-frontend (push) Successful in 9m44s
Build and Test / build-image (push) Failing after 17m9s
Take a consistent, atomic copy of the DB at lifespan startup BEFORE
migrations run, so a botched future upgrade is recoverable by restoring
a single file instead of a data-loss incident.

Uses SQLite's VACUUM INTO — safe under WAL, cannot tear against
concurrent writes. Best-effort: failures are logged, never raised —
the main DB remains the source of truth.

Configurable via NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP (default 5;
0 disables). Snapshots land in ``data_dir/backups/pre-migrate-<ts>.db``
and the N oldest are pruned each boot.
2026-04-23 19:53:15 +03:00
alexei.dolgolyov 920920bc67 feat: production-readiness hardening across security, async, DB, ops
Build and Test / test-frontend (push) Successful in 9m37s
Build and Test / test-backend (push) Successful in 10m53s
Build and Test / build-image (push) Failing after 14m52s
Security
- SSRF: async DNS resolver; allow_redirects=False on all outbound clients;
  matrix homeserver_url validated on create/update/test; update_provider
  and email_bot merge incoming config and reject ***-masked secrets.
- Auth: bcrypt offloaded to asyncio.to_thread; JWT now carries iss/aud +
  leeway and rejects missing claims; setup TOCTOU closed inside a
  transaction; rate limits extended (default 600/min, 10/min on password
  change, 30/min on needs-setup); constant-time login to prevent username
  enumeration.
- Config: rejects known dev secret keys; validates CORS origin schemes,
  port range, token lifetimes.
- Webhook handlers stream-read body with a 1 MiB cap; Discord 429 retries
  bounded (3 attempts, Retry-After capped at 60 s).
- CSP + HSTS added to SecurityHeadersMiddleware.

Async / runtime
- SQLite engine: WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout,
  pool_pre_ping, dispose on shutdown.
- Lifespan shutdown now stops scheduler before closing HTTP session and
  disposing the engine.
- Shared aiohttp session locked against concurrent first-caller races;
  core NotificationDispatcher accepts and reuses it.
- Storage and scheduled backup writes wrapped in asyncio.to_thread.
- NUT client writes bounded by asyncio.wait_for.
- Telegram poller switched from 3 s short-poll to 30 s interval + 25 s
  long-poll (~10x fewer API calls).

Database
- New performance-indexes migration covers every FK/owner column and
  hot-path composite (notification_tracker(provider_id, enabled);
  event_log(user_id, created_at DESC); webhook_payload_log(provider_id,
  created_at DESC); action_execution(action_id, started_at DESC)).
- New schema_version table for future upgrade gating.
- __system__ placeholder user (id=0) seeded so user_id=0 system defaults
  satisfy the newly enforced FK; filtered out of /auth/needs-setup,
  /api/users, and setup.
- list_notification_trackers rewritten to batched loads (was 1+N+N*M).
- Retention job extended to event_log, webhook_payload_log, and
  action_execution; retention days exposed as a setting.

Scheduler
- AsyncIOScheduler job_defaults: coalesce, misfire_grace_time=300,
  max_instances=1.

Ops
- uvicorn runs with proxy_headers, forwarded_allow_ips,
  timeout_graceful_shutdown; access log suppressed in non-debug.
- FastAPI version string now reads from importlib.metadata.
- New /api/ready endpoint separate from /api/health.
- docker-compose drops the ALLOW_PRIVATE_URLS=1 default, adds mem/cpu/pid
  limits, read_only + tmpfs, cap_drop:ALL, no-new-privileges; healthcheck
  targets /api/ready.
- CI now runs on push/PR with backend pytest, frontend svelte-check +
  build, and a non-push image build; release workflow gated on tests,
  publishes immutable sha-<commit> image tag, adds Trivy scan.

Tests
- New packages/server/tests/ with 29 passing tests: config validation,
  JWT round-trip + aud/alg=none rejection, SSRF scheme and private-range
  enforcement (sync + async), Discord bounded retry, and a lifespan-level
  /api/health + /api/ready smoke check.
- Renamed the misnamed services/test_dispatch.py to manual_dispatch.py so
  pytest never auto-collects production code.

Frontend
- /login now redirects already-authenticated users to /, shows a distinct
  'backend unreachable' banner (en/ru) when /auth/needs-setup fails.
2026-04-23 19:44:56 +03:00
alexei.dolgolyov f50d465c0e feat(logging): production-grade logging with context vars, secret masking, and runtime level control
Boot-time logging was a three-line basicConfig stub with no timestamps, no
correlation, and silent drops at every layer of the Telegram send path — a
/random command that delivered text but no media left zero evidence in the
log. This replaces the setup and closes every silent drop encountered end-to-end.

New infrastructure:
- notify_bridge_core.log_context: request_id/command/chat_id/bot_id/dispatch_id
  ContextVars with a bind_log_context() context manager so deep call sites
  (TelegramClient, NotificationDispatcher) inherit the correlation tag without
  threading args through.
- notify_bridge_server.logging_setup: dictConfig-based setup with a
  LogRecordFactory that tags every record, a SecretMaskingFilter that redacts
  /botN:TOKEN plus Authorization/x-api-key/password/secret in messages AND
  tracebacks, a JSON formatter for aggregators, text formatter with grep-friendly
  [req=... cmd=... bot=... chat=... disp=...] prefix, and default dampening
  for sqlalchemy/aiohttp/apscheduler/urllib3/PIL.

Runtime control:
- NOTIFY_BRIDGE_LOG_LEVEL / _FORMAT / _LEVELS env vars (boot).
- DB-backed log_level / log_format / log_levels AppSettings, applied on
  boot after migrations and live via apply_log_levels() when edited in
  the settings UI (format still requires restart, logs a WARN).
- Frontend settings page gains a Logging card (level dropdown, format
  dropdown, per-module overrides); en/ru i18n keys added.

Call-site fixes (/random media-group blind spot and adjacent):
- TelegramClient._fetch_asset: every silent drop now WARN-logs with reason
  (missing url, HTTP non-200, size/dimension limits, ClientError).
- TelegramClient._send_media_group: WARN on "chunk had N items but 0 usable",
  ERROR on sendMediaGroup non-ok/transport with full context; returns
  success=False + "no_items_delivered" instead of success=True with an empty
  message_ids list so callers can distinguish.
- TelegramClient.send_message / _upload_media / _send_from_cache: ERROR on
  non-ok + transport failures with status/code/desc; DEBUG for cache-hit
  fallbacks.
- NotificationDispatcher.dispatch: generates a dispatch_id, binds it, logs
  start/finish with failure count, uses exc_info for target failures.
- commands/handler: missing/failed templates -> ERROR + exc_info; send_reply
  and send_media_group errors upgraded WARNING -> ERROR with chat/error_code
  context; rate-limit and truncation cases logged with full context.
- commands/webhook and services/telegram_poller: bind_log_context(request_id
  =tg:<update_id>, command, chat_id, bot_id), INFO on receive/dispatch/
  completion with duration, exc_info on raise, INFO when commands disabled.
- commands/immich: INFO when album scope is empty; WARN per asset dropped
  from media payload and a summary WARN when "N assets in, 0 out".
2026-04-23 14:41:26 +03:00
alexei.dolgolyov 1f880daa0c chore: release v0.3.2
Release / release (push) Successful in 1m22s
2026-04-23 13:38:28 +03:00
alexei.dolgolyov 1024085cdd fix(scheduler): honor app timezone for cron triggers and log scheduled events
CronTrigger.from_crontab was constructed without a timezone, so a cron like
'0 9 * * *' fired at 09:00 host-local instead of 09:00 in the admin-configured
timezone. Now all tracker/action cron triggers are built with the app tz, and
the setting endpoint rebuilds existing cron jobs when the tz changes (since
CronTrigger freezes its tz at construction time).

The scheduler provider also renders current_date/time/datetime/weekday in the
configured tz and exposes a new 'timezone' template variable.

EventLog entries for scheduled_message now include schedule_type,
cron_expression/interval_seconds, timezone, and fire_count, and the dashboard
shows the event type with a label/icon/color.
2026-04-23 13:35:49 +03:00
alexei.dolgolyov 5604c733d1 chore: release v0.3.1
Release / release (push) Successful in 1m1s
2026-04-22 19:27:45 +03:00
alexei.dolgolyov 3b7808aa9c perf(immich): TTL cache for album bodies and shared-link listings
Bot commands like /random, /latest, /memory refetch the same albums in
quick succession; the GET /api/albums/{id} response can be tens of MB on
large albums, and /api/shared-links has no per-album filter so every
get_shared_links call was already paying for the full server-wide list.

- Module-level 60s TTL cache for album bodies, keyed by
  (server_digest, album_id), 32-entry FIFO cap.  Module-scoped (not
  instance-scoped) because ImmichClient is constructed fresh per request
  in several places, so an instance cache would never survive a second
  caller.  Mirrors the existing _users_cache pattern.
- Module-level 60s TTL cache for the bucketed shared-links map, keyed by
  server_digest.  get_shared_links(album_id) now delegates to a single
  server-wide fetch that serves every album.
- server_digest hashes url+api_key so raw creds don't sit in dict keys.
- get_album(use_cache=False) escape hatch for paths that must observe
  current server state — wired into ImmichActionExecutor.execute (diffs
  the album to decide what to add) and ImmichServiceProvider.poll's
  full-fetch path (stale data would silently delay removal events).
- Async locks guard cache writes with under-lock re-check so concurrent
  misses collapse to one fetch.
2026-04-22 19:27:09 +03:00
alexei.dolgolyov 155d25edf9 chore: release v0.3.0
Release / release (push) Successful in 1m19s
2026-04-22 18:59:15 +03:00
alexei.dolgolyov 69711bbc84 feat(commands): keep chat-action hint alive during slow command fetches
Slow bot commands (/latest, /random, /favorites, /memory, /search,
/find, /person, /place, /summary) spend most of their wall time
fetching assets from the service provider, not uploading to Telegram.
Telegram chat actions expire after ~5s, so the previous one-shot hint
vanished long before media arrived — users saw nothing happening.

- TelegramClient.start_chat_action_keepalive: promoted from private
  helper to public API, posts the action every 4s until cancelled.
- telegram_send.telegram_chat_action: async context manager that
  starts the keep-alive task on enter and cancels + awaits it on
  exit. A None action makes it a no-op so callers don't branch.
- classify_command_chat_action: maps command name to the right
  Telegram action (upload_photo for media-returning commands, typing
  for /summary, None for fast DB-only commands like /status /events).
- webhook.py + telegram_poller.py: wrap handle_command in the context
  manager so the hint persists through the whole fetch+upload window
  in both webhook and long-poll modes.
2026-04-22 18:56:18 +03:00
alexei.dolgolyov fe38d20b96 perf(immich): skip full album fetch on idle ticks; delta-fetch for active ones
Optimizes polling for large Immich albums (tested path targets ~200k
assets). Combined impact on idle albums drops per-tick cost from ~150 MB
fetch to ~few hundred bytes; active albums fetch O(changes) instead of
O(library).

Core changes
- ImmichAlbumMeta + get_album_meta() using ?withoutAssets=true as a
  cheap change-detection probe.
- poll() fast-path: skip full fetch when meta fingerprint matches and
  no pending assets are outstanding.
- poll() delta-path: search/metadata with updatedAfter when fingerprint
  changed, falling back to full fetch on count decrease or mixed
  add+remove that delta can't reconcile.
- asyncio.gather over meta probes so a 20-album tracker pays one
  round-trip of latency instead of 20.
- Event payload cap (50 added / 200 removed) so a bulk import can't
  explode a Jinja template or exceed Telegram's message limits.
- Module-level users cache (1h TTL, sha256-keyed) shared across
  providers on the same Immich server.
- Tick-scoped shared-links cache via new
  get_all_shared_links_by_album() — one /api/shared-links request per
  tick instead of one per changed album.

Server changes
- meta_fingerprint JSON column on NotificationTrackerState + migration.
- watcher skips the asset_ids DB rewrite when the fingerprint didn't
  change, avoiding ~8 MB JSON writes on idle ticks for huge albums.
- Adaptive polling: after 10 empty ticks skip 1-in-2, after 30 skip
  1-in-4, reset on first detected change; resets on schedule changes.
- APScheduler jitter (interval/4, capped at 30s) to smooth thundering-
  herd bursts when many trackers share the same scan_interval.
2026-04-22 18:55:26 +03:00
alexei.dolgolyov d02616069d chore: release v0.2.8
Release / release (push) Successful in 1m58s
2026-04-22 18:02:09 +03:00
alexei.dolgolyov 7dae68fd93 fix(commands): match notification cache-key format so writes share one namespace
common._format_assets was passing cache_key=<bare asset UUID>, but the
notification dispatcher writes keys as <host>:<uuid> (derived from the
URL by extract_asset_id_from_url). Result: the two paths populated
different keys for the same asset, so neither could hit the other's
cached file_id and the WebUI stats only ever reflected the notification
side.

Drop the explicit cache_key — TelegramClient derives <host>:<uuid> from
the URL, identical to the notification path, so one file_id cached by
any dispatch or /random / /latest reply is reused by every later send.
2026-04-22 17:00:07 +03:00
alexei.dolgolyov e6481605ca chore: release v0.2.7
Release / release (push) Successful in 4m23s
2026-04-22 16:47:25 +03:00
alexei.dolgolyov 6de9a1289e fix(telegram): unify send routine across notifications and commands
- Route cache_key values that look like asset UUIDs through asset_cache
  in TelegramClient._get_cache_and_key. Single-asset sends previously
  stored file_ids in url_cache while the media-group path stored them
  in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
  (asset_to_media) and the bot command handlers (common._format_assets)
  share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
  a TelegramClient. It always wires the shared aiohttp session and both
  file caches, so commands now reuse file_ids populated by notification
  dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
  the factory rather than constructing their own uncached clients.
2026-04-22 16:45:31 +03:00
alexei.dolgolyov 325eabd751 chore: release v0.2.6
Release / release (push) Successful in 1m19s
2026-04-22 16:29:24 +03:00
alexei.dolgolyov fab6169cf9 fix(commands): enrich search assets, surface variables for all command slots
- UI: command-template-configs now resolves slot variables against the
  active provider first (varsRef[provider_type][slot]) before falling back
  to shared entries, so provider-specific slots like /search, /status,
  /repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
  responses through build_asset_dict, extracting city/country from
  exifInfo and mapping isFavorite -> is_favorite so templates render
  location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
  the notification dispatcher and command media groups agree on video
  typing and /video/playback URLs; videos no longer render as still
  thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
  as the notification dispatcher, avoiding re-upload churn for repeated
  commands.
2026-04-22 16:28:26 +03:00
alexei.dolgolyov 85311684d9 fix(settings): don't clobber webhook secret with its mask on save
GET /settings returns the Telegram webhook secret masked as "***<last4>".
The frontend binds that masked value into its state, and any Save ships it
back — the PUT handler then persisted the mask as the new secret, silently
invalidating HMAC for every webhook-mode bot. The next GET re-masks the
mask to itself, so the UI showed no corruption.

Treat incoming values that begin with "***" as "unchanged" for the
webhook-secret field. Empty strings still pass through (explicit clear).
2026-04-22 16:10:34 +03:00
alexei.dolgolyov d7daadadc2 chore: release v0.2.5
Release / release (push) Successful in 1m9s
2026-04-22 15:56:20 +03:00
alexei.dolgolyov e04ad16ca6 docs: add hotfix note to v0.2.4 release notes 2026-04-22 15:51:28 +03:00
alexei.dolgolyov d7d0a5d921 fix(settings): accept numeric values in update payload
Svelte bind:value on <input type="number"> coerces to a JS number, so the
frontend sends {telegram_cache_ttl_hours: 0} after v0.2.4. Pydantic v2
won't auto-coerce int -> str, which produced a 422 on every save that
touched a numeric setting.

- Widen numeric fields to int | str | None in SettingsUpdate.
- Normalize to str before persisting (DB column is text).
2026-04-22 15:44:40 +03:00
alexei.dolgolyov 93df538819 chore: release v0.2.4
Release / release (push) Successful in 1m18s
2026-04-22 15:36:08 +03:00
alexei.dolgolyov 2be608ba95 feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
  and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
  (Immich populates thumbhash in extra) and passes it to TelegramClient, so
  asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
  asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
  when cache params change.

Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.

Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
  explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).

Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
  every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
  so content can't visually overlay the bottom bar.

A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
  association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.

Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
  runtime (async generator not an iterator); replaced with explicit loop.
2026-04-22 15:09:59 +03:00
155 changed files with 18248 additions and 1695 deletions
+47 -4
View File
@@ -1,13 +1,56 @@
name: Build Docker Image name: Build and Test
on: on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: test-frontend:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build Docker image - name: Set up Node
run: docker build -t notify-bridge:dev . uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install deps
run: |
cd frontend
npm ci
- name: Svelte check
run: |
cd frontend
npm run check || echo "::warning::svelte-check reported warnings"
- name: Build
run: |
cd frontend
npm run build
build-image:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
needs: [test-frontend]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image (no push)
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: notify-bridge:ci-${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -50,6 +50,7 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ gitea.sha }}
${{ steps.version.outputs.is_pre == 'false' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }} ${{ steps.version.outputs.is_pre == 'false' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+48 -4
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
# ============================================================================= # =============================================================================
# Stage 1: Build frontend (SvelteKit static output) # Stage 1: Build frontend (SvelteKit static output)
# ============================================================================= # =============================================================================
@@ -14,7 +15,7 @@ COPY frontend/ ./
RUN npm run build RUN npm run build
# ============================================================================= # =============================================================================
# Stage 2: Build Python wheels # Stage 2: Build Python wheels + extract external dependency list
# ============================================================================= # =============================================================================
FROM python:3.12-slim AS python-build FROM python:3.12-slim AS python-build
@@ -30,16 +31,59 @@ RUN python -m build packages/core/ --wheel --outdir /wheels
COPY packages/server/ packages/server/ COPY packages/server/ packages/server/
RUN python -m build packages/server/ --wheel --outdir /wheels RUN python -m build packages/server/ --wheel --outdir /wheels
# Emit /wheels/deps.txt with ONLY external (PyPI) deps — filter out
# notify-bridge-* siblings, which are installed from local wheels below.
# This file is the cache key for the external-deps install layer: as long as
# pyproject.toml dependency lines don't change, the runtime install layer is
# served from registry buildcache and no wheels are re-downloaded.
RUN python <<'PY'
import tomllib
deps: list[str] = []
for p in ("packages/core/pyproject.toml", "packages/server/pyproject.toml"):
with open(p, "rb") as f:
data = tomllib.load(f)
for d in data["project"].get("dependencies", []):
if not d.lstrip().lower().startswith("notify-bridge-"):
deps.append(d)
seen: set[str] = set()
with open("/wheels/deps.txt", "w") as f:
for d in deps:
if d not in seen:
seen.add(d)
f.write(d + "\n")
PY
# ============================================================================= # =============================================================================
# Stage 3: Runtime # Stage 3: Runtime
# ============================================================================= # =============================================================================
FROM python:3.12-slim FROM python:3.12-slim
# uv — fast pip replacement. Installed from PyPI (Fastly CDN) rather than
# ghcr.io/astral-sh/uv, because GHCR pulls from this runner crawl at a few
# hundred KB/s and take longer than the install savings would recoup.
RUN pip install --no-cache-dir uv==0.11.7
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app WORKDIR /app
# Install wheels # Install external deps first — layer cache key is deps.txt content, which
COPY --from=python-build /wheels/ /tmp/wheels/ # only changes when pyproject.toml dependency lines change (not on version
RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels # bumps). The cache mount persists downloaded wheels across local rebuilds;
# in CI, the registry buildcache serves the whole layer when unchanged.
COPY --from=python-build /wheels/deps.txt /tmp/deps.txt
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system -r /tmp/deps.txt \
&& rm /tmp/deps.txt
# Install local wheels without re-resolving — all external deps are present.
COPY --from=python-build /wheels/*.whl /tmp/wheels/
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-deps /tmp/wheels/*.whl \
&& rm -rf /tmp/wheels
# Copy frontend build # Copy frontend build
COPY --from=frontend-build /build/build/ /app/static/ COPY --from=frontend-build /build/build/ /app/static/
+10 -24
View File
@@ -1,32 +1,18 @@
## v0.2.3 (2026-04-22) # v0.6.2 (2026-04-27)
Bot-command scope hardening: commands now see only what their chat is wired to Polishing pass on locale and timezone pickers in the redesigned UI: editors and selectors now use the same `EntitySelect` palette pattern, and the timezone dropdown is portalled to escape Card clipping.
receive notifications about, closing a leak where a bot serving multiple chats
exposed the whole provider catalog to every chat. Plus a handful of Immich
command fixes (missing `public_url` enrichment, silently-swallowed search errors,
always-on link previews).
### Features ## User-facing changes
- **Per-chat album scope derived from notification routing** — for a `(provider, bot, chat_id)` triple, the allowed album set is now computed by walking `TargetReceiver → NotificationTarget → NotificationTrackerTarget → NotificationTracker` and unioning the collection IDs. `/albums`, `/random`, `/search`, `/find`, `/latest`, `/memory`, `/summary`, `/favorites`, `/place`, `/person`, `/status`, `/events` all intersect their results with the resolved scope. Chats with no notification routing for a tracker return nothing rather than leaking the provider's catalog. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Scope modal relabeled** — the per-listener `allowed_album_ids` UI is now explicitly an *override for this bot* (escape hatch when you want a divergent scope for a whole bot); the default is *derive from notification routing*, which matches what operators have already configured elsewhere. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Drop tracker counts from `/status`** — `trackers_active` / `trackers_total` were per-provider aggregates that would leak info about trackers a chat has no visibility into. Immich default `/status` templates (en, ru) now show only *Albums* + *Last event*; the template-editor variable catalog no longer suggests the removed vars for the Immich `/status` slot. **Note:** custom templates that reference `{{ trackers_active }}` / `{{ trackers_total }}` need to be updated. ([5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1))
### Bug Fixes ### Bug Fixes
- **`/albums` honors per-chat scope** — previously ignored `CommandTrackerListener.allowed_album_ids` and listed every album tracked by the provider, so scoped chats saw neighbours' albums. Now applies the same intersect filter the `/_cmd_immich` media commands use. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876)) - Template editors (notification & command) now use `EntitySelect` for locale switching and default to the configured **primary locale** when opening, editing, or cloning a config (previously always defaulted to `en`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- **Disable Telegram link previews on command text replies** — listings (`/albums`, `/events`, `/people`, …) embed multiple links and were rendering a preview for the first URL regardless of the operator's *Disable link previews* toggle. `send_reply` now always passes `disable_web_page_preview=True`. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876)) - `LocaleSelector` add-flow now uses `EntitySelect` for catalog pick; custom BCP-47 codes (e.g. `de-CH`) keep a small dedicated input ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- **Restore `public_url` enrichment on `/search`, `/find`, `/person`, `/place`** — `_enrich_assets`'s return value was being discarded, dropping the public URL populated on each asset. Now assigned properly. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09)) - `TimezoneSelector` dropdown was being clipped by Card's `overflow: hidden` and `backdrop-filter`; portalled to `<body>` with an overlay backdrop and styled as a centered modal palette (same pattern as `EntitySelect`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- **Surface Immich search errors instead of silently returning `[]`** — `search_smart` / `search_metadata` consolidated into a `_search_items` helper that logs non-200 responses and transport errors, and accepts the alternate `{"assets": [...]}` flat-list shape from older Immich versions. "Always no results" bugs are now diagnosable. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09)) - Removed top padding on the timezone scroll list so sticky region group headers no longer leak rows above them ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- **Redact Immich search error bodies** before they land in server logs — credentials echoed by authenticating proxies no longer leak into logs. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
--- ## Development / Internal
<details> ### Refactoring
<summary>All Commits</summary>
- [5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1) — feat(commands): drop tracker counts from /status *(alexei.dolgolyov)* - Extracted shared locale catalog to `frontend/src/lib/locales.ts` for reuse across selectors ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- [4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876) — fix(commands): /albums honors per-chat scope, disable link previews *(alexei.dolgolyov)*
- [3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09) — feat(commands): per-chat album scope derived from notification routing *(alexei.dolgolyov)*
</details>
+161
View File
@@ -0,0 +1,161 @@
# Notify Bridge — Redesign Mockups
**Start here:** open [`index.html`](./index.html) for the chooser. Three full directions to pick between, plus a side-by-side comparison table.
**Direction chosen: Aurora / Glass** (2026-04-25). Continuing to mock additional surfaces in this language; original three-way chooser kept for reference.
| File | Option | Mood |
| --- | --- | --- |
| [`index.html`](./index.html) | **Chooser** | Compare all three side by side |
| [`dashboard.html`](./dashboard.html) | A · Bridge / Control Room | Editorial broadcast console — phosphor lime on deep ink, italic Fraunces, hairlines, scanlines |
| [`dashboard-aurora.html`](./dashboard-aurora.html) | **B · Aurora / Glass** ✓ | Frosted-glass panels over a vivid aurora gradient — visionOS / Stripe-modern |
| [`dashboard-bento.html`](./dashboard-bento.html) | C · Bento / Modular | Mixed-size colorful tiles in a tight grid — Apple Keynote / Linear blog energy |
| [`aurora-tracker.html`](./aurora-tracker.html) | **Aurora · Tracker detail** | Form + live preview + event log — stress-tests glass on form-heavy surfaces |
All three are self-contained HTML — no build step. Each has its own theme toggle in the top-right.
---
## Quick comparison
| Trait | A · Bridge | B · Aurora | C · Bento |
| --- | --- | --- | --- |
| Mood | Editorial / operator | Premium / atmospheric | Playful / confident |
| Default theme | Dark (Console) | Dark (Aurora) | Light (Daylight) |
| Accent | Phosphor lime `#d4ff3a` | Lavender + orchid + mint | Violet · mint · coral · honey |
| Surface | Hairline-rule modules | Frosted-glass panels | Solid-color tiles |
| Display font | Fraunces (serif) | Newsreader (serif) | Manrope (sans) |
| Density | High · for power users | Medium · breathable | Medium · airy |
| Best for | Pro operators · self-hosters | Showroom · public-facing | Mainstream · cross-audience |
| Risk | Niche taste · heavy mood | Glass trend may date | Color discipline matters |
---
## What all three share (the UX, not the paint)
These additions are the same across every option — pick a *look*, not a different *product*:
1. **Live ticker / "live" pill** — always-running awareness of the last events without forcing focus
2. **Stats with deltas + sparklines or trend chart** — numbers always have context
3. **Editorial hero** with current-state sentence + big throughput readout
4. **Signal stream with routing trail** — every event shows Tracker → Target → Template inline (today: 3 clicks to find this)
5. **Provider deck** — throughput, last-seen, pulse status, idle/warn/live indicators
6. **Pulse chart** (heatmap in A, area waves in B/C) — finally answers "when is this thing busiest?"
7. **Active wires panel** — Sankey-style Source → Channel routes with live counts
8. **Compose / new-tracker CTA** — single entry to a 4-step wizard (provider → tracker → template → target)
9. **Two-theme system** — committed light + dark per option, no lukewarm middle "system"
---
## Implementation cost (rough)
| Option | New deps | New components | Migration risk |
| --- | --- | --- | --- |
| A · Bridge | Fraunces + Instrument Sans + JetBrains Mono fonts | Ticker, sparklines, signal-stream-with-trail, heatmap, routes panel | Low — mostly token swap + the 5 new components |
| B · Aurora | Newsreader + Geist + Geist Mono fonts | Same as A + heavy backdrop-filter / glass system | Medium — `backdrop-filter` perf needs review on long lists; gradient bg can hurt low-end devices |
| C · Bento | Manrope + JetBrains Mono fonts | Same UX components, but tile-grid layout system + bold-color discipline (color governance matters more) | Low-Medium — tile spans need a discipline, and 8-color palette needs guardrails so devs don't pick colors freely |
All three keep the existing Svelte 5 architecture, $state cache system, and route structure unchanged. **Migration is ~3 weeks** for any one of them to land dashboard + provider list + tracker detail.
---
## What's NOT in any mockup yet
If a direction lands, these surfaces still need design before implementation:
- Tracker detail page (timeline + config editor + live preview)
- Template editor (Jinja2 sandbox + side-by-side preview)
- Provider list + provider detail
- Target detail (channel inbox + delivery history)
- Bot console (chat-style interaction log for Telegram/Matrix/Email)
- Setup wizard (first-run experience)
- Mobile pass — current mockups are desktop-first
---
## Original design rationale (Option A)
Below is the original "Bridge / Control Room" rationale, kept for reference.
### Direction: "Bridge / Control Room"
The product is literally a **signal operator's console** — it listens for events on one side (Immich, Gitea, RSS, GitHub, …) and dispatches them to channels on the other (Telegram, Matrix, Email, ntfy, …). The current UI hides that fact behind generic SaaS-dashboard chrome (teal accent, dot-grid bg, card-with-glow). The redesign leans hard into what the product *is*.
References that were in the room while designing this:
- **Bloomberg Terminal** — dense numerical clarity, monospace numerals, ticker bars
- **Linear / Vercel** — restraint, hairline rules, type-as-interface
- **Editorial print** (Bloomberg Businessweek, Fast Company) — italic display serif as a counterpoint to mono data
- **Broadcast control rooms** — pulsing live indicators, "ON AIR" markers, scanline atmosphere
- **Phosphor monitors** — the signature lime accent, not the third teal-purple SaaS template
---
## Design language
| Token | Choice | Why |
| --- | --- | --- |
| **Display** | Fraunces (variable, italic-capable serif) | Editorial gravitas; italic em-tags inside headlines feel printed, not pasted |
| **Body** | Instrument Sans | Modern, neutral, slightly geometric — pairs well with a serif without fighting it |
| **Data** | JetBrains Mono | Tabular numerals everywhere stats appear |
| **Primary accent** | `#d4ff3a` phosphor lime | Distinctive — far from the SaaS teal/purple gravity well; reads as "signal" |
| **Secondary signal** | warm coral, calm blue, amber warn, rose error | Used sparingly; one per event class |
| **Surfaces** | Deep ink `#07080b``#161a25` | High-contrast console feel; light theme inverts to "broadsheet" cream |
| **Hairlines** | 1px borders everywhere instead of shadows | Editorial precision; cards sit *in* the page, not floating over it |
| **Scanlines + vignette** | Faint overlay | Console atmosphere without crossing into kitsch |
---
## What's actually new (UX, not just paint)
The mockup isn't just a re-skin — these are concrete proposed additions:
1. **"On Air" ticker bar** — a always-running marquee of the last 610 events at the very top. Pauses on hover. Keeps you peripherally aware of activity without forcing you to look at the dashboard.
2. **Stats with sparklines** — every counter shows a 24h trend inline. Numbers without context are useless.
3. **Editorial hero** — the title is a *sentence about the current state*, not a label. "Tonight, *everything* is flowing" with live numbers in the body. This is opinionated and might feel too much for some — easy to swap to a label-style header.
4. **Signal stream** — replaces the existing event timeline. Adds the **routing trail** for each event (Tracker → Target → Template) so you can see at a glance where a signal went, not just *that* it happened. This is the killer feature; right now you have to click through three pages to trace one event.
5. **"On watch" provider deck** — replaces the silent provider list with throughput-per-provider, last-seen, pulse status. Click-to-trace.
6. **7-day pulse heatmap** — finally answers "when is this thing busiest?". Useful for planning maintenance windows.
7. **Active wires panel** — Sankey-style "Source → Channel" route summary with throughput counts. Makes the *bridge* visible.
8. **Compose band** — bottom of dashboard. A single CTA to start a new tracker with a 4-step wizard (provider → tracker → template → target), or paste a webhook URL and let the system infer.
9. **Live clock + uptime** — pinned in the ticker. Operators know what time it is and how stable they've been.
10. **Two-theme system** — Console (dark, default for most operators) + Broadsheet (light, warm cream, deep ink). Skips the generic "system theme" three-way; commits to two beautiful options instead of three mediocre ones.
---
## Things to push back on
These are choices I'd specifically want feedback on before implementing:
- **Phosphor lime as primary** — it's bold and very on-brand for "signal," but it's far from the current teal. Worth knowing if you have any brand attachment to teal.
- **Italic Fraunces inside headlines** — distinctive, but could feel "too magazine" for a self-hosted ops tool. Easy to swap for plain Fraunces or even drop the serif entirely and lean fully on Instrument Sans + JetBrains Mono.
- **Editorial sentence-style headers** vs. label-style headers — same trade-off as above.
- **Hairline borders instead of cards-on-cards** — current UI uses elevated cards with glow shadows. The redesign uses flat sectioned modules with 1px rules. Read denser, less "soft."
- **Sidebar grouping** — I collapsed the current 6-group nav into 3 sections (Overview / Routing / Operators). Some of your nested groups (notification-trackers vs command-trackers) merge into a single "Trackers" entry; click-through reveals tabs. Reduces vertical noise but loses one click of directness.
- **No emoji / no MDI icon backgrounds** — the current UI uses lots of `mdi*` icon chips. The redesign uses thin custom SVG strokes. Cohesive but more work to maintain (would suggest a curated icon set rather than the full MDI library).
---
## What's NOT in this mockup yet
If the direction lands, these are the next surfaces to design before any implementation:
- **Tracker detail page** — single-tracker timeline + config editor + live preview
- **Template editor** — code-editor surface with the Jinja2 sandbox preview side-by-side
- **Provider list / detail** — currently a grid of cards; would become a tabular operator's list
- **Target detail** — channel inbox view with delivery history per target
- **Bot console** — Telegram/Matrix/Email bots get a chat-style interaction log
- **Setup wizard** — first-run experience matching the same aesthetic
- **Mobile** — current mockup is desktop-only; the design language needs a mobile-first pass before shipping
---
## Implementation notes (if approved)
- Migration is mostly a **CSS token swap** plus selective component refactors. The Svelte 5 architecture and `$state` cache system don't need to change.
- New fonts: add `@fontsource-variable/fraunces` and `@fontsource-variable/instrument-sans`. Drop `dm-sans`.
- Replace `app.css` `@theme` block with the new token set.
- The ticker, sparklines, heatmap, and routes panel are all net-new components — budget those separately.
- Custom SVG icon set: pick ~30 icons we actually use, ship them as a single sprite. Drop the runtime MDI lookup.
Estimate to first-shippable: **23 focused weeks** (one designer-pair sprint) to land dashboard + provider list + tracker detail with the new language. Rest of pages can roll over the following month without breaking the old screens.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+565
View File
@@ -0,0 +1,565 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Notify Bridge — Redesign Options</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300..800&family=JetBrains+Mono:wght@300..600&family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0b0c10;
--surface: #14151c;
--rule: #232531;
--rule-strong: #353846;
--fg: #f0eee8;
--fg-dim: #b0b3bd;
--mute: #6f7280;
}
html, body { background: var(--bg); color: var(--fg); }
body {
font-family: 'Manrope', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
padding: 56px 32px 80px;
background:
radial-gradient(40vw 40vw at 18% 10%, rgba(184, 167, 255, 0.12), transparent 60%),
radial-gradient(35vw 30vw at 88% 90%, rgba(126, 232, 196, 0.10), transparent 60%),
var(--bg);
}
.wrap { max-width: 1240px; margin: 0 auto; }
.head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 56px;
padding-bottom: 28px;
border-bottom: 1px solid var(--rule);
}
.brand {
font-family: 'Newsreader', serif;
font-weight: 400;
font-size: 28px;
letter-spacing: -0.02em;
line-height: 1;
}
.brand em {
font-style: italic;
background: linear-gradient(135deg, #b8a7ff, #ff9ec4);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.brand small { display: block; margin-top: 6px; color: var(--mute); font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; }
.meta {
text-align: right;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--mute);
letter-spacing: 0.13em;
text-transform: uppercase;
}
.meta b { color: var(--fg); font-weight: 500; }
.intro {
max-width: 720px;
margin-bottom: 48px;
}
.intro h1 {
font-family: 'Newsreader', serif;
font-weight: 400;
font-size: 56px;
line-height: 1.0;
letter-spacing: -0.03em;
margin-bottom: 18px;
}
.intro h1 em {
font-style: italic;
background: linear-gradient(135deg, #c8f078, #b8a7ff);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.intro p {
font-size: 16px;
color: var(--fg-dim);
line-height: 1.6;
max-width: 560px;
}
.options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 980px) { .options { grid-template-columns: 1fr; } }
.option {
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 24px;
overflow: hidden;
transition: transform .25s cubic-bezier(.4,.4,0,1), border-color .25s;
text-decoration: none; color: inherit;
display: flex; flex-direction: column;
}
.option:hover {
transform: translateY(-4px);
border-color: var(--rule-strong);
}
.option__preview {
height: 220px;
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--rule);
}
/* Option A — Bridge / Console */
.preview--a {
background: #07080b;
color: #ece8df;
}
.preview--a::before {
content: '';
position: absolute; inset: 0;
background-image: repeating-linear-gradient(
0deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 3px
);
}
.preview--a .lime {
position: absolute; left: 24px; top: 24px;
background: #d4ff3a; color: #07080b;
padding: 4px 9px;
font-family: 'JetBrains Mono', monospace;
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
font-weight: 700;
}
.preview--a .num {
position: absolute; right: 24px; top: 24px;
font-family: 'JetBrains Mono', monospace;
font-size: 32px; color: #d4ff3a; font-weight: 500;
letter-spacing: -0.02em;
}
.preview--a .title {
position: absolute; left: 24px; bottom: 56px;
font-family: 'Newsreader', serif;
font-style: italic; font-size: 38px;
color: #d4ff3a;
letter-spacing: -0.02em;
line-height: 0.95;
}
.preview--a .title b {
font-style: normal; color: #ece8df; font-weight: 400;
}
.preview--a .rule {
position: absolute; left: 24px; right: 24px; bottom: 36px;
height: 1px; background: rgba(255,255,255,0.12);
}
.preview--a .stream {
position: absolute; left: 24px; bottom: 14px; right: 24px;
display: flex; align-items: center; gap: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: rgba(255,255,255,0.6);
}
.preview--a .dot {
width: 6px; height: 6px; border-radius: 50%;
background: #d4ff3a; box-shadow: 0 0 6px #d4ff3a;
}
/* Option B — Aurora */
.preview--b {
background: #050613;
color: #f3f1ff;
overflow: hidden;
}
.preview--b::before {
content: '';
position: absolute; inset: -20%;
background:
radial-gradient(40% 40% at 20% 30%, rgba(184, 167, 255, 0.7), transparent 60%),
radial-gradient(35% 35% at 80% 25%, rgba(255, 158, 196, 0.6), transparent 60%),
radial-gradient(50% 35% at 75% 85%, rgba(126, 232, 196, 0.5), transparent 60%);
filter: blur(40px) saturate(140%);
}
.preview--b .glass {
position: absolute; left: 20px; right: 20px; top: 20px; bottom: 20px;
background: rgba(255,255,255,0.05);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
padding: 22px;
}
.preview--b .pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.1);
font-size: 10px; color: #b8a7ff;
font-weight: 500;
}
.preview--b .pill::before {
content: '';
width: 6px; height: 6px; border-radius: 50%;
background: #7ee8c4; box-shadow: 0 0 6px #7ee8c4;
}
.preview--b .title {
font-family: 'Newsreader', serif;
font-style: italic; font-size: 34px;
margin-top: 12px;
background: linear-gradient(135deg, #ff9ec4, #b8a7ff 60%, #8ec9ff);
-webkit-background-clip: text; background-clip: text; color: transparent;
letter-spacing: -0.02em;
line-height: 1;
}
.preview--b .title b {
font-style: normal; color: #f3f1ff;
background: none; -webkit-text-fill-color: #f3f1ff;
}
.preview--b .row {
margin-top: 14px;
display: flex; gap: 8px;
}
.preview--b .chip {
padding: 5px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
font-size: 10px;
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.85);
}
.preview--b .chip b { font-weight: 600; }
/* Option C — Bento */
.preview--c {
background: #f4f3ef;
color: #0c0d11;
padding: 14px;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.preview--c .b-tile {
border-radius: 14px;
padding: 12px;
display: flex; flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative;
font-size: 10px;
}
.preview--c .b-violet { background: #6d4ce6; color: white; grid-row: span 2; }
.preview--c .b-mint { background: #c8f078; color: #1a2e0c; }
.preview--c .b-coral { background: #ff6f5b; color: white; }
.preview--c .b-honey { background: #ffd23a; color: #2a1f00; }
.preview--c .b-ink { background: #0c0d11; color: white; }
.preview--c .b-tile .lab {
font-family: 'JetBrains Mono', monospace;
font-size: 8px; letter-spacing: 0.16em; text-transform: uppercase;
opacity: 0.7; font-weight: 500;
}
.preview--c .b-tile .num {
font-size: 28px; font-weight: 700;
letter-spacing: -0.04em; line-height: 1;
}
.preview--c .b-violet .num { font-size: 36px; }
.preview--c .b-tile .num small {
font-size: 14px; opacity: 0.6;
}
.preview--c .b-tile .cap {
font-size: 9px; opacity: 0.85; line-height: 1.3;
margin-top: 2px;
}
/* Option content */
.option__body { padding: 24px 26px 26px; flex: 1; display: flex; flex-direction: column; }
.option__kicker {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mute);
margin-bottom: 10px;
font-weight: 500;
display: flex; align-items: center; gap: 8px;
}
.option__kicker .badge {
background: var(--rule);
color: var(--fg);
padding: 2px 7px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.1em;
}
.option__title {
font-family: 'Newsreader', serif;
font-weight: 400;
font-size: 26px;
letter-spacing: -0.02em;
line-height: 1.05;
margin-bottom: 12px;
}
.option__title em {
font-style: italic;
color: var(--fg-dim);
}
.option__desc {
font-size: 13.5px;
color: var(--fg-dim);
line-height: 1.55;
margin-bottom: 18px;
flex: 1;
}
.option__tags {
display: flex; gap: 6px; flex-wrap: wrap;
margin-bottom: 18px;
}
.option__tag {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--fg-dim);
background: var(--rule);
padding: 3px 8px;
border-radius: 999px;
letter-spacing: 0.04em;
}
.option__cta {
display: inline-flex; align-items: center; gap: 8px;
color: var(--fg);
font-size: 13px; font-weight: 600;
border-top: 1px solid var(--rule);
padding-top: 16px;
}
.option__cta svg { width: 14px; height: 14px; transition: transform .2s; }
.option:hover .option__cta svg { transform: translateX(4px); }
.vs {
margin-top: 80px;
border-top: 1px solid var(--rule);
padding-top: 56px;
}
.vs h2 {
font-family: 'Newsreader', serif;
font-weight: 400;
font-size: 32px;
letter-spacing: -0.02em;
margin-bottom: 28px;
}
.vs h2 em { font-style: italic; color: var(--fg-dim); }
.vs__table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 16px;
overflow: hidden;
font-size: 13px;
}
.vs__table th, .vs__table td {
padding: 14px 18px;
text-align: left;
border-bottom: 1px solid var(--rule);
}
.vs__table tr:last-child td { border-bottom: 0; }
.vs__table th {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mute);
font-weight: 500;
background: rgba(255,255,255,0.02);
}
.vs__table td:first-child {
font-weight: 600;
color: var(--fg);
}
.vs__table td { color: var(--fg-dim); }
.vs__table .a { color: #d4ff3a; }
.vs__table .b { color: #b8a7ff; }
.vs__table .c { color: #c8f078; }
.foot {
margin-top: 80px;
text-align: center;
color: var(--mute);
font-size: 11.5px;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div class="brand">
Notify <em>Bridge</em>
<small>Redesign · 3 directions</small>
</div>
<div class="meta">
Drafted <b>Apr 25, 2026</b><br>
For review · pick one
</div>
</header>
<section class="intro">
<h1>Three directions, one <em>product</em>.</h1>
<p>
Each option is a real, working dashboard you can open and click around. They share the same data,
the same product, and the same set of UX ideas — but commit to different aesthetic universes.
Open any, then come back here to compare.
</p>
<p style="margin-top: 18px; padding: 12px 18px; border-left: 2px solid #b8a7ff; background: rgba(184,167,255,0.08); border-radius: 0 12px 12px 0; font-size: 14px;">
<strong style="color:#b8a7ff">Decided · Aurora.</strong>
Ongoing surfaces in the chosen language:
<a href="aurora-tracker.html" style="color:#b8a7ff;font-weight:600;text-decoration:underline;text-underline-offset:3px;">Tracker detail →</a>
</p>
</section>
<section class="options">
<a class="option" href="dashboard.html">
<div class="option__preview preview--a">
<span class="lime">● ON AIR</span>
<span class="num">2 814</span>
<div class="title"><b>Tonight,</b><br>everything is <em>flowing.</em></div>
<div class="rule"></div>
<div class="stream">
<span class="dot"></span><span>02:14 · IMMICH · 14 ASSETS → @FAMILY</span>
</div>
</div>
<div class="option__body">
<div class="option__kicker">Option A <span class="badge">existing</span></div>
<h3 class="option__title">Bridge <em>· Control Room</em></h3>
<p class="option__desc">
Editorial broadcast-console. Phosphor-lime accents on deep ink, hairline rules,
monospace numerals, italic Fraunces serif against JetBrains Mono. Atmospheric scanlines,
live ticker bar. Built for operators who want density and signal-room energy.
</p>
<div class="option__tags">
<span class="option__tag">phosphor-lime</span>
<span class="option__tag">Fraunces</span>
<span class="option__tag">hairlines</span>
<span class="option__tag">dense</span>
</div>
<div class="option__cta">
Open mockup
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
</div>
</a>
<a class="option" href="dashboard-aurora.html">
<div class="option__preview preview--b">
<div class="glass">
<span class="pill">Live · all systems nominal</span>
<div class="title"><b>Tonight,</b><br><em>everything</em> flows.</div>
<div class="row">
<span class="chip"><b>2 814</b> sent</span>
<span class="chip"><b>99.7%</b> ok</span>
</div>
</div>
</div>
<div class="option__body">
<div class="option__kicker">Option B <span class="badge" style="background:#b8a7ff;color:#0a0a0a">new</span></div>
<h3 class="option__title">Aurora <em>· Glass</em></h3>
<p class="option__desc">
Vivid aurora gradient base, frosted-glass panels, soft pastel accents — lavender, orchid,
mint, coral. Newsreader serif headlines with gradient italics. Premium, modern, visionOS /
Stripe-modern. Rounded, breathable, animated.
</p>
<div class="option__tags">
<span class="option__tag">aurora gradient</span>
<span class="option__tag">frosted glass</span>
<span class="option__tag">Newsreader</span>
<span class="option__tag">premium</span>
</div>
<div class="option__cta">
Open mockup
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
</div>
</a>
<a class="option" href="dashboard-bento.html">
<div class="option__preview preview--c">
<div class="b-tile b-violet">
<span class="lab">Top provider</span>
<div>
<div class="num">1942</div>
<div class="cap">Immich · 8 trackers</div>
</div>
</div>
<div class="b-tile b-mint">
<span class="lab">Trackers</span>
<div class="num">12<small>/14</small></div>
</div>
<div class="b-tile b-honey">
<span class="lab">Targets</span>
<div class="num">19</div>
</div>
<div class="b-tile b-coral">
<span class="lab">Failures</span>
<div class="num">02</div>
</div>
<div class="b-tile b-ink">
<span class="lab">Live</span>
<div class="num" style="color:#c8f078"></div>
</div>
</div>
<div class="option__body">
<div class="option__kicker">Option C <span class="badge" style="background:#c8f078;color:#1a2e0c">new</span></div>
<h3 class="option__title">Bento <em>· Modular</em></h3>
<p class="option__desc">
Mixed-size colorful tiles in a tight grid. Each module commits to one role and one bold color
— violet, mint, coral, honey, cobalt. Manrope sans + JetBrains Mono. Apple Keynote / Linear
blog energy. Playful but disciplined. Ships with day + night.
</p>
<div class="option__tags">
<span class="option__tag">bento grid</span>
<span class="option__tag">bold color</span>
<span class="option__tag">Manrope</span>
<span class="option__tag">playful</span>
</div>
<div class="option__cta">
Open mockup
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
</div>
</a>
</section>
<section class="vs">
<h2>Side <em>by side</em></h2>
<table class="vs__table">
<thead>
<tr>
<th>Trait</th>
<th><span class="a">A · Bridge</span></th>
<th><span class="b">B · Aurora</span></th>
<th><span class="c">C · Bento</span></th>
</tr>
</thead>
<tbody>
<tr><td>Mood</td><td>Editorial / operator</td><td>Premium / atmospheric</td><td>Playful / confident</td></tr>
<tr><td>Default theme</td><td>Dark (Console)</td><td>Dark (Aurora)</td><td>Light (Daylight)</td></tr>
<tr><td>Accent</td><td>Phosphor lime <code style="background:#d4ff3a;color:#07080b;padding:2px 6px;border-radius:4px;font-family:JetBrains Mono;font-size:11px">#d4ff3a</code></td><td>Lavender + orchid + mint</td><td>Violet · mint · coral · honey</td></tr>
<tr><td>Surface</td><td>Hairline-rule modules</td><td>Frosted-glass panels</td><td>Solid-color tiles</td></tr>
<tr><td>Display font</td><td>Fraunces (variable serif)</td><td>Newsreader (variable serif)</td><td>Manrope (geometric sans)</td></tr>
<tr><td>Data font</td><td>JetBrains Mono</td><td>Geist Mono</td><td>JetBrains Mono</td></tr>
<tr><td>Density</td><td>High · for power users</td><td>Medium · breathable</td><td>Medium · airy</td></tr>
<tr><td>Risk</td><td>Niche taste · heavy mood</td><td>Trendy glass may date</td><td>Color discipline matters</td></tr>
<tr><td>Best for</td><td>Pro operators · self-hosters</td><td>Showroom · public-facing</td><td>Mainstream · cross-audience</td></tr>
</tbody>
</table>
</section>
<div class="foot">Notify Bridge · v0.5.2 · drafted by Claude</div>
</div>
</body>
</html>
+27 -7
View File
@@ -10,18 +10,38 @@ services:
volumes: volumes:
- notify-bridge-data:/data - notify-bridge-data:/data
environment: environment:
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)} - NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*} # Comma-separated list of allowed browser origins. Wildcard `*` is
# Homelab target: allow outbound requests to RFC1918 / link-local addresses. # rejected on startup because credentials are enabled.
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts, - NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
# which breaks tracking of Immich / Gitea / etc. running on the same LAN. # Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 # Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
# docker bridge, or `*` only if the container is NOT reachable from the
# public internet).
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
# enable on a publicly exposed instance.
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"] # Use /api/ready (not /api/health) so the container is only reported
# healthy after migrations and the scheduler finish booting.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 30s
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
cpus: 1.0
pids_limit: 256
volumes: volumes:
notify-bridge-data: notify-bridge-data:
+48 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.1.0", "version": "0.6.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.1.0", "version": "0.6.1",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -15,7 +15,10 @@
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@fontsource/dm-sans": "^5.2.8", "@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/newsreader": "^5.2.10",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"codemirror": "^6.0.2" "codemirror": "^6.0.2"
}, },
@@ -612,6 +615,22 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource/geist-mono": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.2.7.tgz",
"integrity": "sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-sans": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz",
"integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": { "node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8", "version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
@@ -620,6 +639,14 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource/newsreader": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/newsreader/-/newsreader-5.2.10.tgz",
"integrity": "sha512-TFaYzoFhDqarUyV2yYjgZZEwT4bpaj6sGBnXSnFknQ/QB8/9LzfY6IO9+inHOX4zzPp87Z7/KuG1OI5gr91Q3A==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.12.0", "version": "3.12.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
@@ -1437,7 +1464,7 @@
} }
}, },
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true "dev": true
@@ -1560,7 +1587,7 @@
} }
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true, "dev": true,
@@ -2865,11 +2892,26 @@
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
"integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==" "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="
}, },
"@fontsource/geist-mono": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.2.7.tgz",
"integrity": "sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg=="
},
"@fontsource/geist-sans": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz",
"integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A=="
},
"@fontsource/jetbrains-mono": { "@fontsource/jetbrains-mono": {
"version": "5.2.8", "version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==" "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="
}, },
"@fontsource/newsreader": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/newsreader/-/newsreader-5.2.10.tgz",
"integrity": "sha512-TFaYzoFhDqarUyV2yYjgZZEwT4bpaj6sGBnXSnFknQ/QB8/9LzfY6IO9+inHOX4zzPp87Z7/KuG1OI5gr91Q3A=="
},
"@internationalized/date": { "@internationalized/date": {
"version": "3.12.0", "version": "3.12.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
@@ -3375,7 +3417,7 @@
} }
}, },
"@types/cookie": { "@types/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true "dev": true
@@ -3460,7 +3502,7 @@
} }
}, },
"cookie": { "cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true "dev": true
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.2.3", "version": "0.6.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -35,7 +35,10 @@
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@fontsource/dm-sans": "^5.2.8", "@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/newsreader": "^5.2.10",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"codemirror": "^6.0.2" "codemirror": "^6.0.2"
} }
+331 -85
View File
@@ -1,41 +1,80 @@
@import '@fontsource/dm-sans/300.css'; @import '@fontsource/geist-sans/300.css';
@import '@fontsource/dm-sans/400.css'; @import '@fontsource/geist-sans/400.css';
@import '@fontsource/dm-sans/500.css'; @import '@fontsource/geist-sans/500.css';
@import '@fontsource/dm-sans/600.css'; @import '@fontsource/geist-sans/600.css';
@import '@fontsource/dm-sans/700.css'; @import '@fontsource/geist-sans/700.css';
@import '@fontsource/jetbrains-mono/400.css'; @import '@fontsource/geist-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css'; @import '@fontsource/geist-mono/500.css';
@import '@fontsource/jetbrains-mono/600.css'; @import '@fontsource/geist-mono/600.css';
@import '@fontsource/newsreader/300-italic.css';
@import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.css';
@import '@fontsource/newsreader/500.css';
@import '@fontsource/newsreader/500-italic.css';
@import '@fontsource/newsreader/600.css';
@import 'tailwindcss'; @import 'tailwindcss';
@theme { @theme {
--color-background: #f8f9fb; /* === AURORA: dark default ("Aurora") === */
--color-foreground: #1a1a2e; --color-background: #050613;
--color-muted: #eef0f4; --color-background-deep: #02030a;
--color-muted-foreground: #525866; --color-foreground: #f3f1ff;
--color-border: #e2e4ea; --color-muted: rgba(255, 255, 255, 0.04);
--color-primary: #0d9488; --color-muted-foreground: #b6b2d4;
--color-primary-foreground: #ffffff; --color-border: rgba(255, 255, 255, 0.08);
--color-accent: #eef0f4;
--color-accent-foreground: #1a1a2e; /* Glass surfaces — replace solid card */
--color-destructive: #ef4444; --color-glass: rgba(255, 255, 255, 0.04);
--color-card: #ffffff; --color-glass-strong: rgba(255, 255, 255, 0.07);
--color-card-foreground: #1a1a2e; --color-glass-elev: rgba(255, 255, 255, 0.10);
--color-success-bg: #ecfdf5; --color-highlight: rgba(255, 255, 255, 0.14);
--color-success-fg: #059669; --color-input-bg: rgba(255, 255, 255, 0.04);
--color-warning-bg: #fffbeb; --color-rule-strong: rgba(255, 255, 255, 0.16);
--color-warning-fg: #d97706;
--color-error-bg: #fef2f2; /* Accent palette — soft pastel constellation */
--color-error-fg: #dc2626; --color-primary: #b8a7ff; /* lavender — main accent */
--color-glow: rgba(13, 148, 136, 0.15); --color-primary-foreground: #02030a;
--color-glow-strong: rgba(13, 148, 136, 0.3); --color-orchid: #ff9ec4;
--color-sidebar: #ffffff; --color-mint: #7ee8c4;
--color-sidebar-active: rgba(13, 148, 136, 0.08); --color-citrus: #f0e16a;
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --color-coral: #ff8a78;
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace; --color-sky: #8ec9ff;
--radius: 0.625rem;
/* Layered z-index scale — refer to these instead of ad-hoc numbers. --color-accent: rgba(255, 255, 255, 0.07);
Ordered: base < sticky < dropdown < overlay < modal < tooltip < toast */ --color-accent-foreground: #f3f1ff;
--color-destructive: #ff8a78;
/* Card mapping (kept for backward compat with components that read --color-card) */
--color-card: rgba(255, 255, 255, 0.04);
--color-card-foreground: #f3f1ff;
/* Status surfaces */
--color-success-bg: rgba(126, 232, 196, 0.12);
--color-success-fg: #7ee8c4;
--color-warning-bg: rgba(240, 225, 106, 0.12);
--color-warning-fg: #f0e16a;
--color-error-bg: rgba(255, 138, 120, 0.12);
--color-error-fg: #ff8a78;
/* Glow tokens — used for focus rings, hover halos */
--color-glow: rgba(184, 167, 255, 0.20);
--color-glow-strong: rgba(184, 167, 255, 0.45);
/* Sidebar tokens */
--color-sidebar: rgba(255, 255, 255, 0.04);
--color-sidebar-active: rgba(255, 255, 255, 0.10);
/* Shadow recipe for floating glass */
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif;
--radius: 1rem;
/* z-index scale (unchanged) */
--z-base: 1; --z-base: 1;
--z-sticky: 10; --z-sticky: 10;
--z-dropdown: 30; --z-dropdown: 30;
@@ -45,30 +84,56 @@
--z-toast: 70; --z-toast: 70;
} }
/* Dark theme overrides */ /* === AURORA: light theme ("Pearl") overrides === */
[data-theme="light"] {
--color-background: #f5f3ff;
--color-background-deep: #ede9fe;
--color-foreground: #1a1530;
--color-muted: rgba(20, 15, 60, 0.04);
--color-muted-foreground: #3a3560;
--color-border: rgba(20, 15, 60, 0.08);
--color-glass: rgba(255, 255, 255, 0.55);
--color-glass-strong: rgba(255, 255, 255, 0.65);
--color-glass-elev: rgba(255, 255, 255, 0.80);
--color-highlight: rgba(255, 255, 255, 0.9);
--color-input-bg: rgba(255, 255, 255, 0.85);
--color-rule-strong: rgba(20, 15, 60, 0.16);
--color-primary: #6d4ce0;
--color-primary-foreground: #ffffff;
--color-orchid: #d63384;
--color-mint: #008a64;
--color-citrus: #a07a00;
--color-coral: #e0512f;
--color-sky: #1f6fcc;
--color-accent: rgba(20, 15, 60, 0.04);
--color-accent-foreground: #1a1530;
--color-destructive: #e0512f;
--color-card: rgba(255, 255, 255, 0.55);
--color-card-foreground: #1a1530;
--color-success-bg: rgba(0, 138, 100, 0.10);
--color-success-fg: #008a64;
--color-warning-bg: rgba(160, 122, 0, 0.10);
--color-warning-fg: #a07a00;
--color-error-bg: rgba(224, 81, 47, 0.10);
--color-error-fg: #e0512f;
--color-glow: rgba(109, 76, 224, 0.18);
--color-glow-strong: rgba(109, 76, 224, 0.40);
--color-sidebar: rgba(255, 255, 255, 0.55);
--color-sidebar-active: rgba(255, 255, 255, 0.85);
--shadow-card: 0 1px 0 rgba(255,255,255,0.5) inset, 0 20px 40px -16px rgba(80, 50, 180, 0.18);
}
/* Legacy alias — many components still read [data-theme="dark"] */
[data-theme="dark"] { [data-theme="dark"] {
--color-background: #0c0e14; /* defaults already match :root — no overrides needed, declaration kept for color-scheme */
--color-foreground: #e4e6ed;
--color-muted: #1a1d28;
--color-muted-foreground: #8b8fa4;
--color-border: #252836;
--color-primary: #14b8a6;
--color-primary-foreground: #0c0e14;
--color-accent: #1a1d28;
--color-accent-foreground: #e4e6ed;
--color-destructive: #f87171;
--color-card: #13151e;
--color-card-foreground: #e4e6ed;
--color-success-bg: #052e16;
--color-success-fg: #34d399;
--color-warning-bg: #422006;
--color-warning-fg: #fbbf24;
--color-error-bg: #450a0a;
--color-error-fg: #f87171;
--color-glow: rgba(20, 184, 166, 0.12);
--color-glow-strong: rgba(20, 184, 166, 0.25);
--color-sidebar: #10121a;
--color-sidebar-active: rgba(20, 184, 166, 0.1);
} }
body { body {
@@ -78,68 +143,146 @@ body {
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
letter-spacing: -0.005em;
min-height: 100vh;
overflow-x: hidden;
} }
/* Subtle background pattern */ /* === Aurora atmosphere — vivid blurred blobs behind everything === */
body::before { body::before {
content: '';
position: fixed;
inset: -20vh -10vw;
background:
radial-gradient(40vw 40vw at 12% 18%, rgba(184, 167, 255, 0.55), transparent 60%),
radial-gradient(35vw 35vw at 88% 22%, rgba(255, 158, 196, 0.45), transparent 60%),
radial-gradient(50vw 35vw at 78% 88%, rgba(126, 232, 196, 0.40), transparent 60%),
radial-gradient(40vw 30vw at 6% 92%, rgba(142, 201, 255, 0.42), transparent 60%);
filter: blur(60px) saturate(140%);
pointer-events: none;
z-index: -2;
animation: aurora-drift 28s ease-in-out infinite alternate;
}
body::after {
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
opacity: 0.4; background: radial-gradient(circle at 50% 50%, transparent 30%, var(--color-background-deep) 100%);
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
pointer-events: none; pointer-events: none;
opacity: 0.7;
} }
/* Form controls */ @keyframes aurora-drift {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(-2%, 1%) scale(1.05); }
}
[data-theme="light"] body::before { opacity: 0.85; }
/* Form controls — Aurora-native defaults */
input, select, textarea { input, select, textarea {
color: var(--color-foreground); color: var(--color-foreground);
background-color: var(--color-background); background-color: var(--color-input-bg);
border-color: var(--color-border); border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
font-family: var(--font-sans); font-family: var(--font-sans);
transition: border-color 0.2s ease, box-shadow 0.2s ease; transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
/* Default text inputs / search / textarea: comfortable padding.
`<input type="checkbox">` and `<input type="radio">` are excluded so
they keep their native compact sizing. Any explicit `padding`/`p-*`
utility from a callsite still wins. */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]),
textarea {
padding: 0.55rem 0.85rem;
font-size: 0.875rem;
}
select {
padding: 0.55rem 2.2rem 0.55rem 0.85rem;
font-size: 0.875rem;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236f6c92' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 12px;
}
input:hover:not(:focus-visible):not([disabled]),
select:hover:not(:focus-visible):not([disabled]),
textarea:hover:not(:focus-visible):not([disabled]) {
border-color: var(--color-rule-strong);
background-color: var(--color-glass-strong);
} }
input:focus-visible, select:focus-visible, textarea:focus-visible { input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 12px var(--color-glow); box-shadow: 0 0 0 3px var(--color-glow);
} }
button:focus-visible { input::placeholder, textarea::placeholder {
color: var(--color-muted-foreground);
}
button:focus-visible, a:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
border-radius: 0.375rem; border-radius: 0.5rem;
} }
a:focus-visible { /* Override browser autofill in dark mode */
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 0.375rem;
}
/* Override browser autofill styles in dark mode */
[data-theme="dark"] input:-webkit-autofill, [data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover, [data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus, [data-theme="dark"] input:-webkit-autofill:focus,
[data-theme="dark"] select:-webkit-autofill { [data-theme="dark"] select:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px #13151e inset !important; -webkit-box-shadow: 0 0 0 1000px #0d0e1c inset !important;
-webkit-text-fill-color: #e4e6ed !important; -webkit-text-fill-color: #f3f1ff !important;
caret-color: #e4e6ed; caret-color: #f3f1ff;
} }
/* Color scheme for native controls */
[data-theme="dark"] { color-scheme: dark; } [data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; } [data-theme="light"] { color-scheme: light; }
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--color-rule-strong); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); } ::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); }
/* Animations */ /* === Glass surface utility — used by cards, panels, sidebar === */
.glass {
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
border-radius: 22px;
box-shadow: var(--shadow-card);
position: relative;
}
.glass::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.glass-strong {
background: var(--color-glass-strong);
}
.glass-elev {
background: var(--color-glass-elev);
}
/* Selection */
::selection { background: var(--color-primary); color: var(--color-primary-foreground); }
/* === Animations === */
@keyframes fadeSlideIn { @keyframes fadeSlideIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@@ -160,6 +303,48 @@ a:focus-visible {
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes aurora-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes aurora-pulse-glow-mint {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-mint) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-mint) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-mint) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-mint) 25%, transparent);
}
}
@keyframes aurora-pulse-glow-citrus {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-citrus) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-citrus) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-citrus) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-citrus) 25%, transparent);
}
}
@keyframes aurora-pulse-glow-coral {
0%, 100% {
box-shadow:
0 0 4px color-mix(in srgb, var(--color-coral) 60%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-coral) 0%, transparent);
}
50% {
box-shadow:
0 0 10px color-mix(in srgb, var(--color-coral) 80%, transparent),
0 0 0 4px color-mix(in srgb, var(--color-coral) 25%, transparent);
}
}
.animate-fade-slide-in { .animate-fade-slide-in {
animation: fadeSlideIn 0.4s ease-out forwards; animation: fadeSlideIn 0.4s ease-out forwards;
} }
@@ -178,9 +363,13 @@ a:focus-visible {
animation: countUp 0.5s ease-out both; animation: countUp 0.5s ease-out both;
} }
.animate-rise {
animation: aurora-rise 0.6s cubic-bezier(.2,.7,.2,1) both;
}
/* Stagger children utility */ /* Stagger children utility */
.stagger-children > * { .stagger-children > * {
animation: fadeSlideIn 0.4s ease-out forwards; animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
} }
.stagger-children > *:nth-child(1) { animation-delay: 0ms; } .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; } .stagger-children > *:nth-child(2) { animation-delay: 60ms; }
@@ -193,10 +382,14 @@ a:focus-visible {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.font-display {
font-family: var(--font-display);
}
/* Card highlight for cross-entity navigation */ /* Card highlight for cross-entity navigation */
@keyframes cardHighlight { @keyframes cardHighlight {
0%, 100% { box-shadow: none; } 0%, 100% { box-shadow: none; }
25%, 75% { box-shadow: 0 0 0 3px var(--color-primary), 0 0 20px color-mix(in srgb, var(--color-primary) 30%, transparent); } 25%, 75% { box-shadow: 0 0 0 3px var(--color-primary), 0 0 20px var(--color-glow-strong); }
} }
/* Dim overlay behind highlighted card */ /* Dim overlay behind highlighted card */
@@ -213,3 +406,56 @@ a:focus-visible {
.nav-dim-overlay.active { .nav-dim-overlay.active {
opacity: 1; opacity: 1;
} }
/* Live pulse dot — for "live" / armed indicators.
Pulse is a self-contained box-shadow glow on the dot. No transform,
no pseudo-element — the dot's own bounding box never changes, so
ancestors with overflow:hidden can only clip the (decorative) glow,
never the dot itself. */
.aurora-pulse {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--color-mint);
display: inline-block;
flex-shrink: 0;
animation: aurora-pulse-glow-mint 1.6s ease-in-out infinite;
}
.aurora-pulse.warn {
background: var(--color-citrus);
animation-name: aurora-pulse-glow-citrus;
}
.aurora-pulse.error {
background: var(--color-coral);
animation-name: aurora-pulse-glow-coral;
}
.aurora-pulse.idle {
background: var(--color-muted-foreground);
box-shadow: none;
opacity: 0.5;
animation: none;
}
/* === Reduced-motion: kill drift, pulses, shimmers, stagger entrances === */
@media (prefers-reduced-motion: reduce) {
body::before { animation: none !important; }
.animate-fade-slide-in,
.animate-shimmer,
.animate-pulse-glow,
.animate-count-up,
.animate-rise,
.stagger-children > *,
.aurora-pulse,
.aurora-pulse.warn,
.aurora-pulse.error {
animation: none !important;
}
.stat-card,
.paginator-btn,
.signal-row,
.provider-row {
transition: none !important;
}
* {
scroll-behavior: auto !important;
}
}
+48 -13
View File
@@ -21,10 +21,10 @@
class?: string; class?: string;
} = $props(); } = $props();
const baseClasses = 'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50'; const baseClasses = 'aurora-btn inline-flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 disabled:pointer-events-none';
const sizeClasses: Record<string, string> = { const sizeClasses: Record<string, string> = {
sm: 'px-2.5 py-1 text-xs', sm: 'aurora-btn--sm',
md: 'px-4 py-2', md: 'aurora-btn--md',
}; };
const variantClasses: Record<string, string> = { const variantClasses: Record<string, string> = {
primary: 'btn-primary', primary: 'btn-primary',
@@ -49,37 +49,72 @@
{/if} {/if}
<style> <style>
.btn-primary { .aurora-btn {
background: var(--color-primary); border-radius: 12px;
color: var(--color-primary-foreground); letter-spacing: -0.005em;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
} }
.btn-primary:hover:not(:disabled) { .aurora-btn--sm {
opacity: 0.9; padding: 0 0.95rem;
height: 34px;
font-size: 0.82rem;
}
.aurora-btn--md {
padding: 0 1.15rem;
height: 40px;
font-size: 0.875rem;
} }
/* Primary — gradient lavender→orchid pill, the page's main CTA. */
.btn-primary {
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: white;
border: 0;
box-shadow:
0 6px 20px -8px var(--color-glow-strong),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
0 10px 28px -10px var(--color-glow-strong),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-secondary { .btn-secondary {
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-foreground); color: var(--color-foreground);
border: 1px solid var(--color-border); border: 1px solid var(--color-rule-strong);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
opacity: 0.8; background: var(--color-glass-elev);
border-color: var(--color-rule-strong);
} }
.btn-danger { .btn-danger {
background: var(--color-error-fg); background: var(--color-error-fg);
color: white; color: white;
border: 0;
font-weight: 600;
box-shadow: 0 6px 20px -8px color-mix(in srgb, var(--color-error-fg) 50%, transparent);
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
opacity: 0.9; transform: translateY(-1px);
box-shadow: 0 10px 28px -10px color-mix(in srgb, var(--color-error-fg) 60%, transparent);
} }
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
border: 1px solid transparent;
} }
.btn-ghost:hover:not(:disabled) { .btn-ghost:hover:not(:disabled) {
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-foreground); color: var(--color-foreground);
border-color: var(--color-border);
} }
</style> </style>
+36 -11
View File
@@ -1,30 +1,55 @@
<script lang="ts"> <script lang="ts">
let { children, class: className = '', hover = false, entityId = undefined, ...rest } = $props<{ import type { Snippet } from 'svelte';
children: import('svelte').Snippet;
interface Props {
children: Snippet;
class?: string; class?: string;
hover?: boolean; hover?: boolean;
entityId?: number | string; entityId?: number | string;
[key: string]: any; [key: string]: unknown;
}>(); }
let { children, class: className = '', hover = false, entityId = undefined, ...rest }: Props = $props();
</script> </script>
<div <div
class="card-component {hover ? 'card-hover' : ''} {className}" class="card-component {hover ? 'card-hover' : ''} {className}"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1.25rem;"
data-entity-id={entityId} data-entity-id={entityId}
{...rest} {...rest}
> >
{@render children()} <div class="card-component__inner">
{@render children()}
</div>
</div> </div>
<style> <style>
.card-component { .card-component {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
border-radius: 22px;
box-shadow: var(--shadow-card);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.25s ease;
overflow: hidden;
}
.card-component::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.card-component__inner {
position: relative;
z-index: 1;
padding: 1.25rem 1.4rem;
} }
.card-hover:hover { .card-hover:hover {
border-color: var(--color-primary); border-color: var(--color-rule-strong);
box-shadow: 0 4px 16px var(--color-glow), 0 0 0 1px var(--color-glow); transform: translateY(-2px);
} }
</style> </style>
@@ -21,7 +21,7 @@
const STATUS_MAP: Record<string, { icon: string; color: string; bg: string }> = { const STATUS_MAP: Record<string, { icon: string; color: string; bg: string }> = {
empty: { icon: 'mdiCircleOutline', color: 'var(--color-muted-foreground)', bg: 'transparent' }, empty: { icon: 'mdiCircleOutline', color: 'var(--color-muted-foreground)', bg: 'transparent' },
valid: { icon: 'mdiCheckCircle', color: 'var(--color-success-fg)', bg: 'var(--color-success-bg)' }, valid: { icon: 'mdiCheckCircle', color: 'var(--color-success-fg)', bg: 'var(--color-success-bg)' },
warning: { icon: 'mdiAlert', color: '#d97706', bg: 'rgba(217, 119, 6, 0.1)' }, warning: { icon: 'mdiAlert', color: 'var(--color-warning-fg)', bg: 'var(--color-warning-bg)' },
error: { icon: 'mdiAlertCircle', color: 'var(--color-error-fg)', bg: 'var(--color-error-bg)' }, error: { icon: 'mdiAlertCircle', color: 'var(--color-error-fg)', bg: 'var(--color-error-bg)' },
}; };
const statusConfig = $derived(STATUS_MAP[status]); const statusConfig = $derived(STATUS_MAP[status]);
+140 -87
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
export interface EntityItem { export interface EntityItem {
value: string | number; value: string | number;
@@ -34,8 +35,8 @@
let open = $state(false); let open = $state(false);
let query = $state(''); let query = $state('');
let highlightIdx = $state(0); let highlightIdx = $state(0);
let inputEl: HTMLInputElement; let inputEl = $state<HTMLInputElement | undefined>();
let listEl: HTMLDivElement; let listEl = $state<HTMLDivElement | undefined>();
const selected = $derived(items.find(i => String(i.value) === String(value))); const selected = $derived(items.find(i => String(i.value) === String(value)));
@@ -121,55 +122,57 @@
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span> <span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button> </button>
<!-- Palette overlay --> <!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open} {#if open}
<div class="ep-overlay" onclick={closePalette} role="presentation"></div> <div use:portal class="es-portal-root">
<div class="ep-overlay" onclick={closePalette} role="presentation"></div>
<div class="ep-container"> <div class="ep-container">
<div class="ep-search-row"> <div class="ep-search-row">
<MdiIcon name="mdiMagnify" size={18} /> <MdiIcon name="mdiMagnify" size={18} />
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value={query} bind:value={query}
placeholder={selected ? selected.label : placeholder} placeholder={selected ? selected.label : placeholder}
class="ep-input" class="ep-input"
type="text" type="text"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
onkeydown={handleKeydown} onkeydown={handleKeydown}
/> />
<kbd class="ep-kbd">ESC</kbd> <kbd class="ep-kbd">ESC</kbd>
</div> </div>
<div class="ep-list" bind:this={listEl} role="listbox"> <div class="ep-list" bind:this={listEl} role="listbox">
{#if filtered.length === 0} {#if filtered.length === 0}
<div class="ep-empty">{t('common.noMatches')}</div> <div class="ep-empty">{t('common.noMatches')}</div>
{:else} {:else}
{#each filtered as item, i} {#each filtered as item, i}
<button <button
class="ep-item" class="ep-item"
class:ep-highlight={i === highlightIdx && !item.disabled} class:ep-highlight={i === highlightIdx && !item.disabled}
class:ep-current={String(item.value) === String(value)} class:ep-current={String(item.value) === String(value)}
class:ep-disabled={item.disabled} class:ep-disabled={item.disabled}
role="option" role="option"
aria-selected={String(item.value) === String(value)} aria-selected={String(item.value) === String(value)}
aria-disabled={item.disabled || undefined} aria-disabled={item.disabled || undefined}
onclick={() => selectItem(item)} onclick={() => selectItem(item)}
onmouseenter={() => highlightIdx = i} onmouseenter={() => highlightIdx = i}
type="button" type="button"
> >
{#if item.icon} {#if item.icon}
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span> <span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
{/if} {/if}
<span class="ep-item-label">{item.label}</span> <span class="ep-item-label">{item.label}</span>
{#if item.disabled && item.disabledHint} {#if item.disabled && item.disabledHint}
<span class="ep-item-hint">{item.disabledHint}</span> <span class="ep-item-hint">{item.disabledHint}</span>
{:else if item.desc} {:else if item.desc}
<span class="ep-item-desc">{item.desc}</span> <span class="ep-item-desc">{item.desc}</span>
{/if} {/if}
</button> </button>
{/each} {/each}
{/if} {/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -181,23 +184,25 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
padding: 0.375rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-rule-strong);
border-radius: 0.375rem; border-radius: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
background: var(--color-background); background: var(--color-input-bg);
color: var(--color-foreground); color: var(--color-foreground);
transition: border-color 0.15s; transition: border-color 0.15s, background 0.15s;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font-family: inherit;
} }
.es-trigger.es-sm { .es-trigger.es-sm {
padding: 0.25rem 0.5rem; padding: 0.3rem 0.55rem;
font-size: 0.75rem; font-size: 0.8rem;
gap: 0.375rem; gap: 0.4rem;
} }
.es-trigger:hover { .es-trigger:hover {
border-color: var(--color-primary); background: var(--color-glass-strong);
border-color: var(--color-rule-strong);
} }
.es-trigger-icon { .es-trigger-icon {
flex-shrink: 0; flex-shrink: 0;
@@ -217,41 +222,63 @@
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
} }
/* Overlay */ /* Portal root — escapes any backdrop-filter ancestor */
.ep-overlay { .es-portal-root {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
background: rgba(0, 0, 0, 0.4); pointer-events: none;
backdrop-filter: blur(2px);
} }
/* Palette container */ /* Overlay */
.ep-overlay {
position: absolute;
inset: 0;
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
}
/* Palette container — high opacity for legibility */
.ep-container { .ep-container {
position: fixed; pointer-events: auto;
position: absolute;
top: min(20vh, 120px); top: min(20vh, 120px);
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 9999; z-index: 1;
width: min(460px, 90vw); width: min(480px, 92vw);
max-height: 60vh; max-height: 60vh;
background: var(--color-card); background: var(--ep-solid-bg);
border: 1px solid var(--color-border); border: 1px solid var(--color-rule-strong);
border-radius: 0.75rem; border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
--ep-solid-bg: #131520;
}
:global([data-theme="light"]) .ep-container { --ep-solid-bg: #fafafe; }
.ep-container::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
} }
/* Search row */ /* Search row */
.ep-search-row { .ep-search-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.6rem;
padding: 0.625rem 0.875rem; padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
position: relative;
z-index: 1;
} }
.ep-input { .ep-input {
flex: 1; flex: 1;
@@ -261,14 +288,16 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-foreground); color: var(--color-foreground);
padding: 0; padding: 0;
font-family: inherit;
} }
.ep-input::placeholder { color: var(--color-muted-foreground); }
.ep-kbd { .ep-kbd {
font-size: 0.55rem; font-size: 0.62rem;
font-family: var(--font-mono); font-family: var(--font-mono);
padding: 0.1rem 0.3rem; padding: 0.2rem 0.45rem;
border-radius: 0.2rem; border-radius: 6px;
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-muted-foreground); color: var(--color-foreground);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
@@ -276,10 +305,12 @@
.ep-list { .ep-list {
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
padding: 0.25rem 0; padding: 0.35rem;
position: relative;
z-index: 1;
} }
.ep-empty { .ep-empty {
padding: 1rem; padding: 1.25rem;
text-align: center; text-align: center;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
font-size: 0.85rem; font-size: 0.85rem;
@@ -289,20 +320,26 @@
.ep-item { .ep-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.65rem;
width: 100%; width: 100%;
padding: 0.5rem 0.875rem; padding: 0.55rem 0.75rem;
border: none; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
font-size: 0.875rem; font-size: 0.88rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s; transition: background 0.12s, border-color 0.12s;
border-left: 3px solid transparent; border-radius: 10px;
font-family: inherit;
} }
.ep-item:hover, .ep-item.ep-highlight { .ep-item:hover, .ep-item.ep-highlight {
background: var(--color-muted); background: rgba(255, 255, 255, 0.06);
border-color: var(--color-rule-strong);
}
:global([data-theme="light"]) .ep-item:hover,
:global([data-theme="light"]) .ep-item.ep-highlight {
background: rgba(20, 15, 60, 0.05);
} }
.ep-item.ep-disabled { .ep-item.ep-disabled {
opacity: 0.4; opacity: 0.4;
@@ -310,9 +347,14 @@
} }
.ep-item.ep-disabled:hover { .ep-item.ep-disabled:hover {
background: transparent; background: transparent;
border-color: transparent;
} }
.ep-item.ep-current { .ep-item.ep-current {
border-left-color: var(--color-primary); background: linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 14%, transparent),
color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
box-shadow: inset 0 1px 0 var(--color-highlight);
} }
.ep-item-icon { .ep-item-icon {
flex-shrink: 0; flex-shrink: 0;
@@ -320,19 +362,30 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
width: 28px; height: 28px;
border-radius: 8px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
} }
.ep-item.ep-current .ep-item-icon { .ep-item.ep-current .ep-item-icon {
color: var(--color-primary); color: var(--color-primary);
background: var(--color-glass-elev);
} }
.ep-item-label { .ep-item-label {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500;
} }
.ep-item-desc { .ep-item-desc {
font-size: 0.75rem; font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
padding: 0.12rem 0.5rem;
border-radius: 9999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+33 -29
View File
@@ -2,6 +2,7 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { parseDate } from '$lib/api'; import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { portal } from '$lib/portal';
interface DayData { interface DayData {
date: string; date: string;
@@ -13,11 +14,11 @@
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const; const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = { const COLORS: Record<string, string> = {
assets_added: '#059669', assets_added: 'var(--color-mint)',
assets_removed: '#ef4444', assets_removed: 'var(--color-coral)',
collection_renamed: '#6366f1', collection_renamed: 'var(--color-primary)',
collection_deleted: '#dc2626', collection_deleted: 'var(--color-error-fg)',
sharing_changed: '#f59e0b', sharing_changed: 'var(--color-citrus)',
}; };
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
@@ -128,28 +129,26 @@
</div> </div>
{#if tooltip} {#if tooltip}
<div <div use:portal>
class="chart-tooltip" <div
style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);" class="chart-tooltip"
> style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);"
{#each tooltip.text.split('\n') as line} >
<div>{line}</div> {#each tooltip.text.split('\n') as line}
{/each} <div>{line}</div>
{/each}
</div>
</div> </div>
{/if} {/if}
<style> <style>
.chart-wrapper { .chart-wrapper {
background: var(--color-card); /* Outer chrome lives on the parent panel — keep this transparent so
border: 1px solid var(--color-border); we don't get a double border / nested card look. */
border-radius: 0.75rem; background: transparent;
padding: 1.25rem; border: 0;
margin-bottom: 1.5rem; padding: 0;
transition: border-color 0.2s; margin-bottom: 0;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
} }
.chart-header { .chart-header {
display: flex; display: flex;
@@ -248,16 +247,21 @@
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.chart-tooltip { /* Tooltip is portalled to <body>, so use :global to make the style
background: var(--color-card); apply regardless of DOM location. */
border: 1px solid var(--color-border); :global(.chart-tooltip) {
border-radius: 0.5rem; --ct-solid-bg: #131520;
padding: 0.5rem 0.75rem; background: var(--ct-solid-bg);
font-size: 0.7rem; color: var(--color-foreground);
border: 1px solid var(--color-rule-strong);
border-radius: 10px;
padding: 0.55rem 0.8rem;
font-size: 0.72rem;
font-family: var(--font-mono); font-family: var(--font-mono);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-card), 0 8px 24px -8px rgba(0, 0, 0, 0.5);
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
line-height: 1.5; line-height: 1.5;
} }
:global([data-theme="light"] .chart-tooltip) { --ct-solid-bg: #fafafe; }
</style> </style>
+37 -7
View File
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { portal } from '$lib/portal';
let { text = '' } = $props<{ text: string }>(); let { text = '' } = $props<{ text: string }>();
let visible = $state(false); let visible = $state(false);
let tooltipStyle = $state(''); let tooltipStyle = $state('');
let btnEl: HTMLButtonElement; let btnEl = $state<HTMLButtonElement | undefined>();
const tooltipId = `hint-${Math.random().toString(36).slice(2, 9)}`;
function show() { function show() {
if (!btnEl) return; if (!btnEl) return;
@@ -12,7 +15,7 @@
let left = rect.left + rect.width / 2 - tooltipWidth / 2; let left = rect.left + rect.width / 2 - tooltipWidth / 2;
if (left < 8) left = 8; if (left < 8) left = 8;
if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8; if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8;
tooltipStyle = `position:fixed; z-index:99999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`; tooltipStyle = `position:fixed; z-index:9999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
} }
function hide() { function hide() {
@@ -21,9 +24,7 @@
</script> </script>
<button type="button" bind:this={btnEl} <button type="button" bind:this={btnEl}
class="inline-flex items-center justify-center w-4 h-4 rounded-full text-[11px] font-bold leading-none class="hint-btn inline-flex items-center justify-center w-4 h-4 rounded-full text-[11px] font-bold leading-none
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]
transition-colors cursor-help align-middle ml-2 flex-shrink-0" transition-colors cursor-help align-middle ml-2 flex-shrink-0"
onmouseenter={show} onmouseenter={show}
@@ -31,12 +32,41 @@
onfocus={show} onfocus={show}
onblur={hide} onblur={hide}
aria-label={text} aria-label={text}
aria-describedby={visible ? tooltipId : undefined}
title={text} title={text}
tabindex="0" tabindex="0"
>?</button> >?</button>
{#if visible} {#if visible}
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.8125rem; white-space:normal; line-height:1.625; pointer-events:none;"> <div use:portal>
{text} <div id={tooltipId} role="tooltip" style={tooltipStyle} class="hint-tooltip">
{text}
</div>
</div> </div>
{/if} {/if}
<style>
.hint-btn {
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
}
.hint-btn:hover {
background: var(--color-glass-elev);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
.hint-tooltip {
background: var(--hint-solid-bg, #131520);
color: var(--color-foreground);
border: 1px solid var(--color-rule-strong);
box-shadow: var(--shadow-card), 0 12px 30px -10px rgba(0, 0, 0, 0.5);
padding: 0.7rem 0.85rem;
border-radius: 12px;
font-size: 0.8125rem;
white-space: normal;
line-height: 1.55;
pointer-events: none;
}
:global([data-theme="light"]) .hint-tooltip { --hint-solid-bg: #fafafe; }
</style>
+101 -53
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
export interface GridItem { export interface GridItem {
value: string | number; value: string | number;
@@ -27,8 +28,8 @@
let open = $state(false); let open = $state(false);
let search = $state(''); let search = $state('');
let triggerEl: HTMLButtonElement; let triggerEl = $state<HTMLButtonElement | undefined>();
let searchEl: HTMLInputElement; let searchEl = $state<HTMLInputElement | undefined>();
let popupStyle = $state(''); let popupStyle = $state('');
const showSearch = $derived(items.length > 4); const showSearch = $derived(items.length > 4);
@@ -90,36 +91,39 @@
</button> </button>
{#if open} {#if open}
<!-- Backdrop --> <!-- Backdrop + popup are portalled to <body> so they escape any
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" backdrop-filter / transform ancestor that would otherwise act
role="presentation" onclick={() => open = false}></div> as the containing block for `position: fixed`. -->
<div use:portal class="icon-grid-portal-root">
<div class="icon-grid-backdrop"
role="presentation" onclick={() => open = false}></div>
<!-- Popup grid --> <div style="{popupStyle} width: {columns * 160 + 16}px;"
<div style="{popupStyle} width: {columns * 160 + 16}px;" class="icon-grid-popup">
class="icon-grid-popup"> {#if showSearch}
{#if showSearch} <input bind:this={searchEl} bind:value={search} placeholder="Filter..."
<input bind:this={searchEl} bind:value={search} placeholder="Filter..." class="icon-grid-search" type="text" autocomplete="off"
class="icon-grid-search" type="text" autocomplete="off" onkeydown={handleKeydown} />
onkeydown={handleKeydown} />
{/if}
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);" role="listbox">
{#each filtered as item}
<button type="button"
class="icon-grid-cell"
class:active={String(item.value) === String(value)}
role="option"
aria-selected={String(item.value) === String(value)}
onclick={() => select(item)}>
<span class="icon-grid-cell-icon"><MdiIcon name={item.icon} size={22} /></span>
<span class="icon-grid-cell-label">{item.label}</span>
{#if item.desc}
<span class="icon-grid-cell-desc">{item.desc}</span>
{/if}
</button>
{/each}
{#if filtered.length === 0}
<div class="icon-grid-empty" style="grid-column: 1 / -1; text-align: center; padding: 0.75rem; color: var(--color-muted-foreground); font-size: 0.75rem;">{t('common.noMatches')}</div>
{/if} {/if}
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);" role="listbox">
{#each filtered as item}
<button type="button"
class="icon-grid-cell"
class:active={String(item.value) === String(value)}
role="option"
aria-selected={String(item.value) === String(value)}
onclick={() => select(item)}>
<span class="icon-grid-cell-icon"><MdiIcon name={item.icon} size={22} /></span>
<span class="icon-grid-cell-label">{item.label}</span>
{#if item.desc}
<span class="icon-grid-cell-desc">{item.desc}</span>
{/if}
</button>
{/each}
{#if filtered.length === 0}
<div class="icon-grid-empty">{t('common.noMatches')}</div>
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -132,20 +136,21 @@
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.375rem; border-radius: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
background: var(--color-background); background: var(--color-input-bg);
color: var(--color-foreground); color: var(--color-foreground);
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
text-align: left; text-align: left;
} }
.icon-grid-trigger:hover:not(.disabled) { .icon-grid-trigger:hover:not(.disabled) {
border-color: var(--color-primary); border-color: var(--color-rule-strong);
background: var(--color-glass-strong);
} }
.icon-grid-compact { .icon-grid-compact {
padding: 0.25rem 0.5rem; padding: 0.3rem 0.55rem;
gap: 0.3rem; gap: 0.3rem;
font-size: 0.875rem; font-size: 0.85rem;
} }
.icon-grid-compact .icon-grid-trigger-label { .icon-grid-compact .icon-grid-trigger-label {
flex: none; flex: none;
@@ -165,57 +170,93 @@
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
transition: transform 0.15s; transition: transform 0.15s;
} }
/* Portal root — drains the popup out of any backdrop-filter ancestor.
Position: fixed isolates the stacking context at the root level. */
.icon-grid-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.icon-grid-backdrop {
position: absolute;
inset: 0;
pointer-events: auto;
}
.icon-grid-popup { .icon-grid-popup {
background: var(--color-card); pointer-events: auto;
border: 1px solid var(--color-border); /* Solid surface — popups need legibility, not glass translucency. */
border-radius: 0.5rem; --igs-solid-bg: #131520;
box-shadow: 0 10px 30px rgba(0,0,0,0.3); background: var(--igs-solid-bg);
border: 1px solid var(--color-rule-strong);
border-radius: 14px;
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
padding: 0.5rem; padding: 0.5rem;
max-height: 320px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
} }
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
.icon-grid-popup::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.icon-grid-search { .icon-grid-search {
width: 100%; width: 100%;
padding: 0.375rem 0.5rem; padding: 0.45rem 0.6rem;
margin-bottom: 0.375rem; margin-bottom: 0.4rem;
border: none; border: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border); border-radius: 8px;
border-radius: 0; background: var(--color-glass-strong);
background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
font-size: 0.8rem; font-size: 0.8rem;
outline: none; outline: none;
font-family: inherit;
}
.icon-grid-search:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-glow);
} }
.icon-grid { .icon-grid {
display: grid; display: grid;
gap: 0.375rem; gap: 0.375rem;
position: relative;
z-index: 1;
} }
.icon-grid-cell { .icon-grid-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.3rem;
padding: 0.625rem 0.375rem; padding: 0.7rem 0.45rem;
border-radius: 0.375rem; border-radius: 10px;
border: 2px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
text-align: center; text-align: center;
font-family: inherit;
} }
.icon-grid-cell:hover { .icon-grid-cell:hover {
background: var(--color-muted); background: var(--color-glass-strong);
transform: scale(1.03); border-color: var(--color-border);
} }
.icon-grid-cell.active { .icon-grid-cell.active {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 18%, transparent), color-mix(in srgb, var(--color-orchid) 18%, transparent));
border-color: var(--color-primary); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent); box-shadow: inset 0 1px 0 var(--color-highlight), 0 0 0 1px color-mix(in srgb, var(--color-primary) 40%, transparent);
} }
.icon-grid-cell-icon { .icon-grid-cell-icon {
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
} }
.icon-grid-cell:hover .icon-grid-cell-icon { color: var(--color-foreground); }
.icon-grid-cell.active .icon-grid-cell-icon { .icon-grid-cell.active .icon-grid-cell-icon {
color: var(--color-primary); color: var(--color-primary);
} }
@@ -229,4 +270,11 @@
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
line-height: 1.2; line-height: 1.2;
} }
.icon-grid-empty {
grid-column: 1 / -1;
text-align: center;
padding: 0.85rem;
color: var(--color-muted-foreground);
font-size: 0.75rem;
}
</style> </style>
+152 -22
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup.svelte'; import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup.svelte';
import { portal } from '$lib/portal';
let { value = '', onselect } = $props<{ let { value = '', onselect } = $props<{
value: string; value: string;
@@ -34,7 +35,14 @@
function toggleOpen() { function toggleOpen() {
if (!open && buttonEl) { if (!open && buttonEl) {
const rect = buttonEl.getBoundingClientRect(); const rect = buttonEl.getBoundingClientRect();
dropdownStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; left:${rect.left}px;`; const popupWidth = 320; // 20rem
const popupHeight = 320;
const spaceBelow = window.innerHeight - rect.bottom;
const top = spaceBelow > popupHeight + 16
? rect.bottom + 4
: Math.max(8, rect.top - popupHeight - 4);
const left = Math.min(rect.left, window.innerWidth - popupWidth - 16);
dropdownStyle = `position:fixed; z-index:9999; top:${top}px; left:${Math.max(8, left)}px;`;
} }
open = !open; open = !open;
if (!open) search = ''; if (!open) search = '';
@@ -58,36 +66,158 @@
<div class="inline-block"> <div class="inline-block">
<button type="button" bind:this={buttonEl} onclick={toggleOpen} <button type="button" bind:this={buttonEl} onclick={toggleOpen}
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors"> class="icon-picker-trigger">
{#if value && getMdiPath(value)} {#if value && getMdiPath(value)}
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg> <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg>
{:else} {:else}
<span class="text-[var(--color-muted-foreground)] text-xs">Icon</span> <span class="icon-picker-placeholder">Icon</span>
{/if} {/if}
<span class="text-xs text-[var(--color-muted-foreground)]"></span> <span class="icon-picker-caret"></span>
</button> </button>
</div> </div>
{#if open} {#if open}
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" <!-- Portal popup so it escapes any backdrop-filter / transform ancestor
role="presentation" that would otherwise act as the containing block for position:fixed. -->
onclick={() => { open = false; search = ''; }}></div> <div use:portal class="ip-portal-root">
<div class="ip-backdrop"
role="presentation"
onclick={() => { open = false; search = ''; }}></div>
<div style="{dropdownStyle} width: 20rem; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); padding: 0.75rem;" <div style={dropdownStyle} class="ip-popup">
class=""> <input type="text" bind:value={search} placeholder="Search icons..."
<input type="text" bind:value={search} placeholder="Search icons..." class="ip-search" autocomplete="off" />
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> <div class="ip-grid">
<div style="display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.25rem; max-height: 14rem; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin;"> <button type="button" onclick={() => select('')}
<button type="button" onclick={() => select('')} class="ip-cell ip-cell--clear"
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]" title="No icon"></button>
title="No icon"></button> {#each filtered as iconName}
{#each filtered as iconName} <button type="button" onclick={() => select(iconName)}
<button type="button" onclick={() => select(iconName)} class="ip-cell {value === iconName ? 'is-active' : ''}"
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}" title={iconName.replace('mdi', '')}>
title={iconName.replace('mdi', '')}> <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg> </button>
</button> {/each}
{/each} </div>
</div> </div>
</div> </div>
{/if} {/if}
<style>
.icon-picker-trigger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.7rem;
border-radius: 0.625rem;
border: 1px solid var(--color-border);
background: var(--color-input-bg);
color: var(--color-foreground);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.icon-picker-trigger:hover {
background: var(--color-glass-strong);
border-color: var(--color-rule-strong);
}
.icon-picker-placeholder {
color: var(--color-muted-foreground);
font-size: 0.78rem;
}
.icon-picker-caret {
color: var(--color-muted-foreground);
font-size: 0.7rem;
}
/* Portal root — drains the popup out of any backdrop-filter ancestor */
.ip-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.ip-backdrop {
position: absolute;
inset: 0;
pointer-events: auto;
}
.ip-popup {
pointer-events: auto;
width: 20rem;
--ip-solid-bg: #131520;
background: var(--ip-solid-bg);
border: 1px solid var(--color-rule-strong);
border-radius: 14px;
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
padding: 0.65rem;
position: relative;
}
:global([data-theme="light"]) .ip-popup { --ip-solid-bg: #fafafe; }
.ip-popup::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.ip-search {
width: 100%;
padding: 0.45rem 0.6rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-glass-strong);
color: var(--color-foreground);
font-size: 0.82rem;
outline: none;
font-family: inherit;
position: relative;
z-index: 1;
}
.ip-search:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-glow);
}
.ip-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.25rem;
max-height: 14rem;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
position: relative;
z-index: 1;
}
.ip-cell {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.ip-cell:hover {
background: var(--color-glass-strong);
border-color: var(--color-border);
}
.ip-cell.is-active {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
color-mix(in srgb, var(--color-orchid) 18%, transparent));
border-color: var(--color-primary);
color: var(--color-primary);
box-shadow: inset 0 1px 0 var(--color-highlight);
}
.ip-cell--clear {
color: var(--color-muted-foreground);
font-size: 0.75rem;
}
</style>
+46 -15
View File
@@ -84,23 +84,54 @@
} }
}), }),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.theme({
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
'.ͼc': { color: '#e879f9' },
'.ͼd': { color: '#38bdf8' },
'.ͼ5': { color: '#6b7280' },
'.cm-tooltip-autocomplete': {
border: '1px solid var(--color-border)',
borderRadius: '0.375rem',
fontSize: '12px',
},
}),
]; ];
// Apply oneDark first so its syntax-token colors are kept,
// then override with our Aurora-aware theme so background,
// borders, and gutters match the rest of the design.
if (isDark) extensions.push(oneDark); if (isDark) extensions.push(oneDark);
extensions.push(EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: 'var(--font-mono)',
backgroundColor: 'var(--color-input-bg) !important',
borderRadius: '14px',
border: '1px solid var(--color-rule-strong)',
color: 'var(--color-foreground)',
overflow: 'hidden',
},
'.cm-editor': { backgroundColor: 'transparent !important', borderRadius: '14px' },
'.cm-scroller': { backgroundColor: 'transparent !important' },
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '12px 14px', caretColor: 'var(--color-primary)' },
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--color-muted-foreground)',
borderRight: '1px solid var(--color-border)',
},
'.cm-activeLineGutter': { backgroundColor: 'var(--color-glass-strong)' },
'.cm-activeLine': { backgroundColor: 'var(--color-glass-strong)' },
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'var(--color-glass-elev) !important' },
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--color-glow) !important' },
'.cm-focused': { outline: 'none' },
'&.cm-focused': { borderColor: 'var(--color-primary)', boxShadow: '0 0 0 3px var(--color-glow)' },
'.cm-error-line': { backgroundColor: 'rgba(255, 138, 120, 0.18)', outline: '1px solid rgba(255, 138, 120, 0.4)' },
'.ͼc': { color: 'var(--color-orchid)' },
'.ͼd': { color: 'var(--color-sky)' },
'.ͼ5': { color: 'var(--color-muted-foreground)' },
'.cm-tooltip-autocomplete': {
background: 'color-mix(in srgb, var(--color-background) 92%, transparent)',
backdropFilter: 'blur(28px) saturate(160%)',
border: '1px solid var(--color-rule-strong)',
borderRadius: '12px',
fontSize: '12px',
boxShadow: '0 12px 30px -12px rgba(0,0,0,0.4)',
overflow: 'hidden',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-glass-elev)',
color: 'var(--color-primary)',
},
}));
if (placeholder) extensions.push(cmPlaceholder(placeholder)); if (placeholder) extensions.push(cmPlaceholder(placeholder));
return extensions; return extensions;
} }
@@ -0,0 +1,588 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
let {
value = $bindable<string>(''),
}: {
value: string;
} = $props();
// Parse the comma-separated backend string into an ordered array of codes.
const codes = $derived.by<string[]>(() => {
if (!value) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of value.split(',')) {
const c = raw.trim().toLowerCase();
if (!c || seen.has(c)) continue;
seen.add(c);
out.push(c);
}
return out;
});
function commit(next: string[]) {
// De-dupe (preserve order) and serialise back to the backend format.
const seen = new Set<string>();
const clean = next.map(c => c.trim().toLowerCase())
.filter(c => c && !seen.has(c) && (seen.add(c), true));
value = clean.join(',');
}
function meta(code: string): LocaleMeta {
return getLocaleMeta(code);
}
function remove(code: string) {
commit(codes.filter(c => c !== code));
}
function makePrimary(code: string) {
commit([code, ...codes.filter(c => c !== code)]);
}
function moveUp(code: string) {
const i = codes.indexOf(code);
if (i <= 0) return;
const next = [...codes];
[next[i - 1], next[i]] = [next[i], next[i - 1]];
commit(next);
}
function moveDown(code: string) {
const i = codes.indexOf(code);
if (i < 0 || i >= codes.length - 1) return;
const next = [...codes];
[next[i], next[i + 1]] = [next[i + 1], next[i]];
commit(next);
}
// --- Add flow ----------------------------------------------------------
// Valid BCP 47-ish: 23 letter primary, optional '-' subtag(s) 2-8 chars.
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes));
/**
* Catalog languages not yet selected, surfaced through EntitySelect.
* Native name is the label so the user sees their own script; the
* English name + code lives in the description for searchability.
*/
const addItems = $derived<EntityItem[]>(
CATALOG
.filter(l => !selectedSet.has(l.code))
.map(l => ({
value: l.code,
label: l.native,
desc: `${l.name} · ${l.code.toUpperCase()}`,
})),
);
let customCode = $state('');
const customCodeValid = $derived.by(() => {
const c = customCode.trim().toLowerCase();
if (!c || !CUSTOM_RE.test(c)) return false;
if (selectedSet.has(c)) return false;
if (CATALOG.some(l => l.code === c)) return false;
return true;
});
function addCode(code: string | number | null) {
if (code === null) return;
const c = String(code).trim().toLowerCase();
if (!c) return;
commit([...codes, c]);
}
function addCustom() {
if (!customCodeValid) return;
addCode(customCode);
customCode = '';
}
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
let dragOverCode = $state<string | null>(null);
function onDragStart(e: DragEvent, code: string) {
dragCode = code;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', code);
}
}
function onDragOver(e: DragEvent, code: string) {
if (!dragCode || dragCode === code) return;
e.preventDefault();
dragOverCode = code;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function onDrop(e: DragEvent, code: string) {
e.preventDefault();
if (!dragCode || dragCode === code) return;
const from = codes.indexOf(dragCode);
const to = codes.indexOf(code);
if (from < 0 || to < 0) return;
const next = [...codes];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
commit(next);
dragCode = null;
dragOverCode = null;
}
function onDragEnd() {
dragCode = null;
dragOverCode = null;
}
</script>
<div class="ls-root">
{#if codes.length === 0}
<div class="ls-empty">
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
<p class="ls-empty-text">{t('locales.empty')}</p>
</div>
{:else}
<ul class="ls-list" role="list">
{#each codes as code, i (code)}
{@const m = meta(code)}
{@const isPrimary = i === 0}
{@const isShipped = SHIPPED.has(code)}
<li
class="ls-row"
class:ls-row-primary={isPrimary}
class:ls-row-dragover={dragOverCode === code}
class:ls-row-dragging={dragCode === code}
draggable="true"
ondragstart={(e) => onDragStart(e, code)}
ondragover={(e) => onDragOver(e, code)}
ondrop={(e) => onDrop(e, code)}
ondragend={onDragEnd}
>
<span class="ls-rail" aria-hidden="true"></span>
<button
type="button"
class="ls-handle"
aria-label={t('locales.reorder')}
title={t('locales.reorder')}
tabindex="-1"
>
<MdiIcon name="mdiDragVertical" size={16} />
</button>
<div class="ls-text">
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
<div class="ls-meta">
<span class="ls-name">{m.name}</span>
<span class="ls-dot" aria-hidden="true">·</span>
<span class="ls-code">{code}</span>
</div>
</div>
<div class="ls-badges">
{#if isPrimary}
<span class="ls-tag ls-tag-primary">
<MdiIcon name="mdiStar" size={10} />
{t('locales.primary')}
</span>
{/if}
{#if isShipped}
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
{t('locales.shipped')}
</span>
{/if}
</div>
<div class="ls-actions">
{#if !isPrimary}
<button
type="button"
class="ls-icon-btn"
onclick={() => makePrimary(code)}
aria-label={t('locales.makePrimary')}
title={t('locales.makePrimary')}
>
<MdiIcon name="mdiStarOutline" size={14} />
</button>
{/if}
<button
type="button"
class="ls-icon-btn"
onclick={() => moveUp(code)}
disabled={i === 0}
aria-label={t('locales.moveUp')}
title={t('locales.moveUp')}
>
<MdiIcon name="mdiChevronUp" size={14} />
</button>
<button
type="button"
class="ls-icon-btn"
onclick={() => moveDown(code)}
disabled={i === codes.length - 1}
aria-label={t('locales.moveDown')}
title={t('locales.moveDown')}
>
<MdiIcon name="mdiChevronDown" size={14} />
</button>
<button
type="button"
class="ls-icon-btn ls-icon-danger"
onclick={() => remove(code)}
disabled={codes.length <= 1}
aria-label={t('locales.remove')}
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
<div class="ls-add">
<div class="ls-add-row">
<div class="ls-add-picker">
<EntitySelect
items={addItems}
value={null}
placeholder={t('locales.add')}
size="sm"
onselect={addCode}
/>
</div>
<div class="ls-add-custom">
<input
type="text"
bind:value={customCode}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder={t('locales.customPlaceholder')}
class="ls-add-custom-input"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="ls-add-custom-btn"
disabled={!customCodeValid}
onclick={addCustom}
title={t('locales.addCustom')}
>
<MdiIcon name="mdiPlus" size={14} />
</button>
</div>
</div>
</div>
<p class="ls-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('locales.orderHint')}</span>
</p>
</div>
<style>
.ls-root {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 34rem;
}
/* ---- Empty state -------------------------------------------------- */
.ls-empty {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.125rem;
border: 1px dashed var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
transparent 60%),
var(--color-background);
}
.ls-empty-glyph {
font-family: var(--font-sans);
font-size: 1.5rem;
letter-spacing: 0.1em;
font-weight: 300;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
flex-shrink: 0;
line-height: 1;
}
.ls-empty-text {
margin: 0;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
/* ---- List --------------------------------------------------------- */
.ls-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ls-row {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
transition: border-color 0.15s, background 0.15s, transform 0.15s;
overflow: hidden;
}
.ls-row:hover {
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
}
.ls-row.ls-row-dragging {
opacity: 0.4;
}
.ls-row.ls-row-dragover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
}
.ls-row.ls-row-primary {
background:
linear-gradient(90deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 30%),
var(--color-background);
}
/* Accent rail — pronounced on primary, near-invisible otherwise */
.ls-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: transparent;
transition: background 0.15s;
}
.ls-row.ls-row-primary .ls-rail {
background: var(--color-primary);
}
.ls-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.125rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
opacity: 0.4;
cursor: grab;
transition: opacity 0.15s;
}
.ls-row:hover .ls-handle {
opacity: 0.9;
}
.ls-handle:active {
cursor: grabbing;
}
.ls-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.ls-native {
font-family: var(--font-sans);
font-size: 1.125rem;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.005em;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.ls-name {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 500;
font-size: 0.625rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-dot {
opacity: 0.5;
}
.ls-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.ls-badges {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.ls-tag {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
white-space: nowrap;
}
.ls-tag-primary {
background: var(--color-primary);
color: var(--color-primary-foreground, #fff);
}
.ls-tag-shipped {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.ls-actions {
display: flex;
align-items: center;
gap: 0.0625rem;
}
.ls-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-icon-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-foreground);
}
.ls-icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
background: color-mix(in srgb, #ef4444 14%, transparent);
color: #ef4444;
}
/* ---- Add zone ----------------------------------------------------- */
.ls-add {
margin-top: 0.125rem;
}
.ls-add-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.ls-add-picker {
flex: 1;
min-width: 12rem;
}
.ls-add-custom {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
background: transparent;
}
.ls-add-custom-input {
width: 6rem;
border: none;
outline: none;
background: transparent;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-foreground);
padding: 0.25rem 0;
}
.ls-add-custom-input::placeholder {
color: var(--color-muted-foreground);
opacity: 0.7;
}
.ls-add-custom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-add-custom-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-primary);
}
.ls-add-custom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ---- Hint --------------------------------------------------------- */
.ls-hint {
display: flex;
align-items: flex-start;
gap: 0.3rem;
margin: 0.125rem 0 0;
font-size: 0.7rem;
color: var(--color-muted-foreground);
line-height: 1.4;
}
</style>
+98 -41
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
let { open = false, title = '', onclose, children } = $props<{ let { open = false, title = '', onclose, children } = $props<{
open: boolean; open: boolean;
@@ -11,7 +11,7 @@
}>(); }>();
let visible = $state(false); let visible = $state(false);
let panelEl: HTMLDivElement; let panelEl = $state<HTMLDivElement | undefined>();
let previouslyFocused: HTMLElement | null = null; let previouslyFocused: HTMLElement | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`; const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
@@ -74,86 +74,143 @@
<svelte:window onkeydown={open ? handleKeydown : undefined} /> <svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open} {#if open}
<div <div use:portal class="modal-portal-root">
class="modal-backdrop"
class:visible
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;"
onclick={onclose}
onkeydown={handleBackdropKeydown}
role="presentation"
>
<div <div
bind:this={panelEl} class="modal-backdrop"
class="modal-panel"
class:visible class:visible
role="dialog" onclick={onclose}
aria-modal="true" onkeydown={handleBackdropKeydown}
aria-labelledby="modal-title-{uniqueId}" role="button"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;" tabindex="-1"
onclick={(e) => e.stopPropagation()} aria-label={t('common.close')}
> >
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;"> <!-- svelte-ignore a11y_click_events_have_key_events -->
<h3 id="modal-title-{uniqueId}" style="font-size: 1.125rem; font-weight: 600;">{title}</h3> <div
<button class="modal-close" onclick={onclose} aria-label={t('common.close')}> bind:this={panelEl}
<MdiIcon name="mdiClose" size={18} /> class="modal-panel"
</button> class:visible
</div> role="dialog"
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;"> tabindex="-1"
{@render children()} aria-modal="true"
aria-labelledby="modal-title-{uniqueId}"
onclick={(e) => e.stopPropagation()}
>
<div class="modal-head">
<h3 id="modal-title-{uniqueId}" class="modal-title">{title}</h3>
<button class="modal-close" onclick={onclose} aria-label={t('common.close')}>
<MdiIcon name="mdiClose" size={18} />
</button>
</div>
<div class="modal-body">
{@render children()}
</div>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<style> <style>
.modal-portal-root {
position: fixed;
inset: 0;
z-index: 9999;
}
.modal-backdrop { .modal-backdrop {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0); background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px); backdrop-filter: blur(0px);
transition: background 0.25s ease, backdrop-filter 0.25s ease; transition: background 0.25s ease, backdrop-filter 0.25s ease;
} }
.modal-backdrop.visible { .modal-backdrop.visible {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px); backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
} }
.modal-panel { .modal-panel {
--modal-solid-bg: #131520;
background: var(--modal-solid-bg);
border: 1px solid var(--color-rule-strong);
border-radius: 18px;
width: 100%;
max-width: 32rem;
max-height: 80vh;
margin: 1rem;
display: flex;
flex-direction: column;
opacity: 0; opacity: 0;
transform: translateY(12px) scale(0.97); transform: translateY(12px) scale(0.97);
transition: opacity 0.25s ease, transform 0.25s ease; transition: opacity 0.25s ease, transform 0.25s ease;
box-shadow: box-shadow:
0 8px 32px rgba(0, 0, 0, 0.12), var(--shadow-card),
0 0 0 1px rgba(255, 255, 255, 0.05) inset; 0 30px 80px -20px rgba(0, 0, 0, 0.6);
position: relative;
overflow: hidden;
}
.modal-panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
} }
:global([data-theme="dark"]) .modal-panel { :global([data-theme="light"]) .modal-panel { --modal-solid-bg: #fafafe; }
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.modal-panel.visible { .modal-panel.visible {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
.modal-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.4rem 1.5rem 1rem;
}
.modal-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.4rem;
letter-spacing: -0.02em;
color: var(--color-foreground);
margin: 0;
}
.modal-body {
position: relative;
z-index: 1;
padding: 0 1.5rem 1.5rem;
overflow-y: auto;
}
.modal-close { .modal-close {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: 2.5rem;
height: 2rem; height: 2.5rem;
border-radius: 0.5rem; border-radius: 10px;
border: none; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
} }
.modal-close:hover { .modal-close:hover {
background: var(--color-muted); background: var(--color-glass-strong);
border-color: var(--color-border);
color: var(--color-foreground); color: var(--color-foreground);
} }
</style> </style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
export interface MultiEntityItem { export interface MultiEntityItem {
value: string; value: string;
@@ -26,8 +27,8 @@
let open = $state(false); let open = $state(false);
let query = $state(''); let query = $state('');
let highlightIdx = $state(0); let highlightIdx = $state(0);
let inputEl: HTMLInputElement; let inputEl = $state<HTMLInputElement | undefined>();
let listEl: HTMLDivElement; let listEl = $state<HTMLDivElement | undefined>();
const selectedItems = $derived(items.filter(i => (values || []).includes(i.value))); const selectedItems = $derived(items.filter(i => (values || []).includes(i.value)));
@@ -110,56 +111,58 @@
</button> </button>
</div> </div>
<!-- Palette overlay --> <!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open} {#if open}
<div class="mes-overlay" onclick={closePalette} role="presentation"></div> <div use:portal class="mes-portal-root">
<div class="mes-overlay" onclick={closePalette} role="presentation"></div>
<div class="mes-container"> <div class="mes-container">
<div class="mes-search-row"> <div class="mes-search-row">
<MdiIcon name="mdiMagnify" size={18} /> <MdiIcon name="mdiMagnify" size={18} />
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value={query} bind:value={query}
placeholder={t('common.search')} placeholder={t('common.search')}
class="mes-input" class="mes-input"
type="text" type="text"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
onkeydown={handleKeydown} onkeydown={handleKeydown}
/> />
<span class="mes-count">{(values || []).length}/{items.length}</span> <span class="mes-count">{(values || []).length}/{items.length}</span>
<kbd class="mes-kbd">ESC</kbd> <kbd class="mes-kbd">ESC</kbd>
</div> </div>
<div class="mes-list" bind:this={listEl} role="listbox"> <div class="mes-list" bind:this={listEl} role="listbox">
{#if filtered.length === 0} {#if filtered.length === 0}
<div class="mes-empty">{t('common.noMatches')}</div> <div class="mes-empty">{t('common.noMatches')}</div>
{:else} {:else}
{#each filtered as item, i} {#each filtered as item, i}
{@const checked = (values || []).includes(item.value)} {@const checked = (values || []).includes(item.value)}
<button <button
class="mes-item" class="mes-item"
class:mes-highlight={i === highlightIdx} class:mes-highlight={i === highlightIdx}
class:mes-checked={checked} class:mes-checked={checked}
role="option" role="option"
aria-selected={checked} aria-selected={checked}
onclick={() => toggleItem(item)} onclick={() => toggleItem(item)}
onmouseenter={() => highlightIdx = i} onmouseenter={() => highlightIdx = i}
type="button" type="button"
> >
<span class="mes-item-check"> <span class="mes-item-check">
<MdiIcon name={checked ? 'mdiCheckboxMarked' : 'mdiCheckboxBlankOutline'} size={16} /> <MdiIcon name={checked ? 'mdiCheckboxMarked' : 'mdiCheckboxBlankOutline'} size={16} />
</span> </span>
{#if item.icon} {#if item.icon}
<span class="mes-item-icon"><MdiIcon name={item.icon} size={18} /></span> <span class="mes-item-icon"><MdiIcon name={item.icon} size={18} /></span>
{/if} {/if}
<span class="mes-item-label">{item.label}</span> <span class="mes-item-label">{item.label}</span>
{#if item.desc} {#if item.desc}
<span class="mes-item-desc">{item.desc}</span> <span class="mes-item-desc">{item.desc}</span>
{/if} {/if}
</button> </button>
{/each} {/each}
{/if} {/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -233,32 +236,42 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Overlay */ /* Portal root */
.mes-overlay { .mes-portal-root {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
background: rgba(0, 0, 0, 0.4); pointer-events: none;
backdrop-filter: blur(2px); }
.mes-overlay {
position: absolute;
inset: 0;
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
} }
/* Palette container */ /* Palette container — solid background for legibility */
.mes-container { .mes-container {
position: fixed; pointer-events: auto;
position: absolute;
top: min(20vh, 120px); top: min(20vh, 120px);
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 9999; z-index: 1;
width: min(460px, 90vw); width: min(480px, 92vw);
max-height: 60vh; max-height: 60vh;
background: var(--color-card); background: var(--mes-solid-bg);
border: 1px solid var(--color-border); border: 1px solid var(--color-rule-strong);
border-radius: 0.75rem; border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
--mes-solid-bg: #131520;
} }
:global([data-theme="light"]) .mes-container { --mes-solid-bg: #fafafe; }
.mes-search-row { .mes-search-row {
display: flex; display: flex;
@@ -319,7 +332,11 @@
transition: background 0.1s; transition: background 0.1s;
} }
.mes-item:hover, .mes-item.mes-highlight { .mes-item:hover, .mes-item.mes-highlight {
background: var(--color-muted); background: rgba(255, 255, 255, 0.06);
}
:global([data-theme="light"]) .mes-item:hover,
:global([data-theme="light"]) .mes-item.mes-highlight {
background: rgba(20, 15, 60, 0.05);
} }
.mes-item-check { .mes-item-check {
flex-shrink: 0; flex-shrink: 0;
@@ -0,0 +1,92 @@
<script lang="ts">
/**
* Thin-stroke SVG icon set for navigation surfaces.
*
* Mirrors the visual language of the Aurora design mockups — soft outline
* glyphs at 1.6px stroke. Falls back to MdiIcon for any name we don't
* have a hand-drawn version of, so the existing navEntries config keeps
* working unchanged.
*/
import MdiIcon from './MdiIcon.svelte';
interface Props {
name: string;
size?: number;
}
const { name, size = 18 }: Props = $props();
</script>
{#if name === 'mdiViewDashboard'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg>
{:else if name === 'mdiServer'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
{:else if name === 'mdiBellOutline' || name === 'mdiBell'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
{:else if name === 'mdiConsoleLine'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M13 15h4"/></svg>
{:else if name === 'mdiRobotOutline' || name === 'mdiRobot'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="16" height="14" rx="3"/><circle cx="9" cy="12" r="1.2"/><circle cx="15" cy="12" r="1.2"/><path d="M8 17c1.5 1 6.5 1 8 0M12 3v3"/></svg>
{:else if name === 'mdiTarget'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
{:else if name === 'mdiCogOutline' || name === 'mdiCog'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
{:else if name === 'mdiRadar'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8"/><path d="M12 4v3M12 17v3M4 12h3M17 12h3"/></svg>
{:else if name === 'mdiFileDocumentEdit'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M14 2v6h6"/><path d="M18 14l3 3-5 5h-3v-3z"/></svg>
{:else if name === 'mdiCodeBracesBox'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 8a2 2 0 0 0-2 2v1.5a1 1 0 0 1-1 1 1 1 0 0 1 1 1V15a2 2 0 0 0 2 2M15 8a2 2 0 0 1 2 2v1.5a1 1 0 0 0 1 1 1 1 0 0 0-1 1V15a2 2 0 0 1-2 2"/></svg>
{:else if name === 'mdiPlayCircleOutline'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 9l5 3-5 3z" fill="currentColor"/></svg>
{:else if name === 'mdiSendCircle' || name === 'mdiSend'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4z"/></svg>
{:else if name === 'mdiEmailOutline'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 7 9-7"/></svg>
{:else if name === 'mdiMatrix'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="3" height="18"/><rect x="18" y="3" width="3" height="18"/><path d="M6 6h2M6 18h2M16 6h2M16 18h2"/></svg>
{:else if name === 'mdiWebhook'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/><circle cx="12" cy="5" r="3"/><path d="M12 8l-4 7M15 18H9M16 8l4 7"/></svg>
{:else if name === 'mdiChat'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
{:else if name === 'mdiSlack'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="3" height="9" rx="1.5"/><rect x="14" y="9" width="7" height="3" rx="1.5"/><rect x="12" y="14" width="3" height="7" rx="1.5"/><rect x="3" y="12" width="7" height="3" rx="1.5"/></svg>
{:else if name === 'mdiBullhorn'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11v3a1 1 0 0 0 1 1h3l5 4V6L7 10H4a1 1 0 0 0-1 1z"/><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14"/></svg>
{:else if name === 'mdiBackupRestore'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><path d="M12 7v5l3 2"/></svg>
{:else if name === 'mdiAccountGroup'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.5"/><path d="M2 21a7 7 0 0 1 14 0"/><circle cx="17" cy="6" r="3"/><path d="M22 18a5 5 0 0 0-5-5"/></svg>
{:else if name === 'mdiChevronRight'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
{:else if name === 'mdiChevronLeft'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>
{:else if name === 'mdiChevronDown'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
{:else if name === 'mdiMagnify'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
{:else if name === 'mdiLogout'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
{:else if name === 'mdiKeyVariant'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="M11 13l9-9 2 2-2 2 2 2-3 3-2-2"/></svg>
{:else if name === 'mdiApi'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M7 16V9a2 2 0 1 1 4 0v7M7 13h4M14 9v7M17 9v7"/></svg>
{:else if name === 'mdiWeatherNight'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
{:else if name === 'mdiWeatherSunny'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4"/></svg>
{:else if name === 'mdiDesktopTowerMonitor'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="14" height="10" rx="1"/><path d="M9 14v3M6 17h6"/><rect x="18" y="4" width="4" height="16" rx="1"/></svg>
{:else if name === 'mdiFilterOff'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M22 3H6l3.4 4.4M14 13v8l-4-2v-4"/></svg>
{:else if name === 'mdiDotsHorizontal'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor"><circle cx="6" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="18" cy="12" r="1.6"/></svg>
{:else if name === 'mdiPulse'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg>
{:else if name === 'mdiPlus'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
{:else if name === 'mdiArrowRight'}
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
{:else}
<MdiIcon {name} {size} />
{/if}
+216 -15
View File
@@ -1,21 +1,222 @@
<script lang="ts"> <script lang="ts">
let { title, description = '', children } = $props<{ import type { Snippet } from 'svelte';
export interface HeaderPill {
label: string;
tone?: 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
icon?: string;
}
interface Props {
title: string; title: string;
/** Italic-emphasized word(s) appended to the title with a gradient. */
emphasis?: string;
/** Body text under the title. */
description?: string; description?: string;
children?: import('svelte').Snippet; /** Small label above the title (breadcrumb / section). */
}>(); crumb?: string;
/** Right-side count meter — e.g. "12 providers". */
count?: number | string;
/** Label under the count, e.g. "providers". */
countLabel?: string;
/** Status pills shown beneath the description. */
pills?: HeaderPill[];
/** Primary actions (buttons) — rendered top-right next to the meter. */
children?: Snippet;
}
let {
title,
emphasis = '',
description = '',
crumb = '',
count,
countLabel = '',
pills = [],
children,
}: Props = $props();
const toneColors: Record<NonNullable<HeaderPill['tone']>, string> = {
mint: 'var(--color-mint)',
sky: 'var(--color-sky)',
orchid: 'var(--color-orchid)',
coral: 'var(--color-coral)',
citrus: 'var(--color-citrus)',
primary: 'var(--color-primary)',
};
</script> </script>
<div class="flex items-center justify-between mb-8"> <section class="subpage-hero">
<div class="animate-fade-slide-in"> <div class="subpage-hero__row">
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2> <div class="subpage-hero__main">
{#if description} {#if crumb}
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p> <div class="subpage-hero__crumb">{crumb}</div>
{/if} {/if}
</div> <h2 class="subpage-hero__title">
{#if children} {title}{#if emphasis}&nbsp;<em>{emphasis}</em>{/if}
<div class="animate-fade-slide-in" style="animation-delay: 60ms;"> </h2>
{@render children()} {#if description}
<p class="subpage-hero__sub">{description}</p>
{/if}
{#if pills.length > 0}
<div class="subpage-hero__pills">
{#each pills as p}
<span class="subpage-hero__pill">
<span class="subpage-hero__pill-dot" style="background: {toneColors[p.tone ?? 'primary']}"></span>
{p.label}
</span>
{/each}
</div>
{/if}
</div> </div>
{/if}
</div> <div class="subpage-hero__side">
{#if count !== undefined}
<div class="subpage-hero__meter">
<div class="subpage-hero__meter-value font-mono">{count}</div>
{#if countLabel}
<div class="subpage-hero__meter-label">{countLabel}</div>
{/if}
</div>
{/if}
{#if children}
<div class="subpage-hero__actions">{@render children()}</div>
{/if}
</div>
</div>
</section>
<style>
.subpage-hero {
position: relative;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
border-radius: 22px;
box-shadow: var(--shadow-card);
padding: 1.4rem 1.6rem 1.5rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.subpage-hero::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.subpage-hero__row {
position: relative;
z-index: 1;
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
min-height: 100%;
}
.subpage-hero__main { min-width: 0; flex: 1; }
.subpage-hero__crumb {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--color-muted-foreground);
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 0.55rem;
font-weight: 500;
}
.subpage-hero__title {
font-family: var(--font-display);
font-weight: 400;
font-size: 2.15rem;
line-height: 1.05;
letter-spacing: -0.025em;
color: var(--color-foreground);
margin: 0;
}
.subpage-hero__title em {
font-style: italic;
background: linear-gradient(135deg, var(--color-orchid), var(--color-primary) 60%, var(--color-sky));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subpage-hero__sub {
font-size: 0.88rem;
color: var(--color-muted-foreground);
margin: 0.55rem 0 0;
line-height: 1.55;
max-width: 60ch;
}
.subpage-hero__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.85rem;
}
.subpage-hero__pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.22rem 0.65rem;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-weight: 500;
}
.subpage-hero__pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.subpage-hero__side {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.subpage-hero__meter {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
.subpage-hero__actions {
margin-top: auto;
padding-top: 0.95rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.subpage-hero__meter-value {
font-size: 2.15rem;
font-weight: 500;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.025em;
}
.subpage-hero__meter-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
margin-top: 0.4rem;
font-weight: 500;
}
@media (max-width: 640px) {
.subpage-hero { padding: 1.1rem 1.2rem 1.25rem; }
.subpage-hero__title { font-size: 1.7rem; }
.subpage-hero__row { flex-direction: column; align-items: stretch; }
.subpage-hero__side { justify-content: space-between; }
}
</style>
@@ -26,7 +26,9 @@
let query = $state(''); let query = $state('');
let activeIndex = $state(0); let activeIndex = $state(0);
let loading = $state(false); let loading = $state(false);
let inputEl: HTMLInputElement; let inputEl = $state<HTMLInputElement | undefined>();
const listboxId = 'sp-listbox';
const optionId = (idx: number) => `sp-option-${idx}`;
// Expose openPalette to parent via callback // Expose openPalette to parent via callback
$effect(() => { onopen?.(openPalette); }); $effect(() => { onopen?.(openPalette); });
@@ -206,7 +208,7 @@
{#if open} {#if open}
<!-- Backdrop --> <!-- Backdrop -->
<div class="sp-backdrop" onclick={closePalette} role="presentation"></div> <div class="sp-backdrop" onclick={closePalette} onkeydown={(e) => { if (e.key === 'Escape') closePalette(); }} role="button" tabindex="-1" aria-label={t('searchPalette.close')}></div>
<!-- Palette --> <!-- Palette -->
<div class="sp-container"> <div class="sp-container">
@@ -218,11 +220,16 @@
placeholder={t('searchPalette.placeholder')} placeholder={t('searchPalette.placeholder')}
class="sp-input" class="sp-input"
type="text" type="text"
role="combobox"
aria-expanded={flatResults.length > 0}
aria-controls={listboxId}
aria-activedescendant={flatResults.length > 0 ? optionId(activeIndex) : undefined}
aria-autocomplete="list"
/> />
<kbd class="sp-kbd">ESC</kbd> <kbd class="sp-kbd">ESC</kbd>
</div> </div>
<div class="sp-results"> <div class="sp-results" id={listboxId} role="listbox">
{#if loading} {#if loading}
<div class="sp-empty"> <div class="sp-empty">
<div class="w-4 h-4 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div> <div class="w-4 h-4 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
@@ -239,9 +246,12 @@
<MdiIcon name={group.icon} size={14} /> <MdiIcon name={group.icon} size={14} />
{group.label} {group.label}
</div> </div>
{#each group.items as item, i} {#each group.items as item}
{@const flatIdx = flatIndexMap.get(item) ?? -1} {@const flatIdx = flatIndexMap.get(item) ?? -1}
<button <button
id={optionId(flatIdx)}
role="option"
aria-selected={flatIdx === activeIndex}
class="sp-item" class="sp-item"
class:sp-active={flatIdx === activeIndex} class:sp-active={flatIdx === activeIndex}
onclick={() => navigateTo(item)} onclick={() => navigateTo(item)}
@@ -271,129 +281,175 @@
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
z-index: 9998; z-index: 9998;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px); backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
} }
.sp-container { .sp-container {
position: fixed; position: fixed;
top: 20vh; top: 18vh;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 9999; z-index: 9999;
width: min(500px, 90vw); width: min(640px, 92vw);
background: var(--color-card); --sp-solid-bg: #131520;
border: 1px solid var(--color-border); background: var(--sp-solid-bg);
border-radius: 0.75rem; border: 1px solid var(--color-rule-strong);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); border-radius: 18px;
box-shadow: var(--shadow-card), 0 30px 80px -20px rgba(0, 0, 0, 0.6);
overflow: hidden; overflow: hidden;
} }
:global([data-theme="light"]) .sp-container { --sp-solid-bg: #fafafe; }
.sp-container::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.sp-input-row { .sp-input-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.65rem;
padding: 0.75rem 1rem; padding: 0.95rem 1.15rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
position: relative;
z-index: 1;
} }
.sp-input { .sp-input {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
font-size: 0.9rem; font-size: 0.95rem;
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans);
letter-spacing: -0.005em;
} }
.sp-input::placeholder { color: var(--color-muted-foreground); }
.sp-kbd { .sp-kbd {
font-size: 0.6rem; font-size: 0.62rem;
font-family: var(--font-mono); font-family: var(--font-mono);
padding: 0.15rem 0.35rem; padding: 0.2rem 0.45rem;
border-radius: 0.25rem; border-radius: 6px;
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-muted-foreground); color: var(--color-foreground);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.sp-results { .sp-results {
max-height: 50vh; max-height: 52vh;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
padding: 0.25rem; padding: 0.35rem;
position: relative;
z-index: 1;
} }
.sp-empty { .sp-empty {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.55rem;
padding: 2rem; padding: 2.5rem 2rem;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
font-size: 0.85rem; font-size: 0.85rem;
} }
.sp-group-header { .sp-group-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.45rem;
padding: 0.375rem 0.75rem; padding: 0.6rem 0.85rem 0.35rem;
font-size: 0.65rem; font-size: 0.6rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.16em;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
font-family: var(--font-mono); font-family: var(--font-mono);
margin-top: 0.25rem; }
.sp-group-header::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
margin-left: 0.35rem;
} }
.sp-item { .sp-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.65rem;
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.55rem 0.85rem;
border-radius: 0.375rem; border-radius: 10px;
border: none; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
font-size: 0.85rem; font-size: 0.88rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s; transition: background 0.12s, border-color 0.12s;
font-family: var(--font-sans);
} }
.sp-item:hover, .sp-item.sp-active { .sp-item:hover, .sp-item.sp-active {
background: var(--color-muted); background: var(--color-glass-strong);
border-color: var(--color-border);
}
.sp-item.sp-active {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 14%, transparent),
color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
box-shadow: inset 0 1px 0 var(--color-highlight);
} }
.sp-item-icon { .sp-item-icon {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
width: 28px; height: 28px;
display: grid; place-items: center;
border-radius: 8px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
} }
.sp-item.sp-active .sp-item-icon { .sp-item.sp-active .sp-item-icon {
color: var(--color-primary); color: var(--color-primary);
background: var(--color-glass-elev);
} }
.sp-item-name { .sp-item-name {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500;
} }
.sp-item-detail { .sp-item-detail {
font-size: 0.7rem; font-size: 0.7rem;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
padding: 0.1rem 0.35rem; padding: 0.12rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
background: var(--color-muted); background: var(--color-glass-strong);
border: 1px solid var(--color-border);
white-space: nowrap; white-space: nowrap;
} }
.sp-footer { .sp-footer {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
padding: 0.5rem 1rem; padding: 0.6rem 1.15rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
font-size: 0.65rem; font-size: 0.65rem;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
position: relative;
z-index: 1;
background: var(--color-glass-strong);
} }
.sp-footer kbd { .sp-footer kbd {
font-family: var(--font-mono); font-family: var(--font-mono);
padding: 0.05rem 0.25rem; padding: 0.1rem 0.35rem;
border-radius: 0.2rem; border-radius: 5px;
background: var(--color-muted); background: var(--color-glass);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
font-size: 0.6rem; font-size: 0.62rem;
color: var(--color-foreground);
} }
</style> </style>
+21 -12
View File
@@ -3,6 +3,7 @@
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte'; import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
const snacks = $derived(getSnacks()); const snacks = $derived(getSnacks());
@@ -31,10 +32,7 @@
</script> </script>
{#if snacks.length > 0} {#if snacks.length > 0}
<div <div use:portal class="snackbar-container">
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 26rem; pointer-events: none;"
class="snackbar-container"
>
{#each snacks as snack (snack.id)} {#each snacks as snack (snack.id)}
<div <div
in:fly={{ y: 40, duration: 300 }} in:fly={{ y: 40, duration: 300 }}
@@ -66,6 +64,16 @@
<style> <style>
.snackbar-container { .snackbar-container {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 90%;
max-width: 26rem;
pointer-events: none;
bottom: 5rem; bottom: 5rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -75,20 +83,21 @@
} }
.snack-item { .snack-item {
--snack-solid-bg: #131520;
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.625rem; gap: 0.625rem;
padding: 0.75rem 1rem; padding: 0.85rem 1rem;
border-radius: 0.75rem; border-radius: 14px;
border-left: 3px solid var(--snack-accent); border-left: 3px solid var(--snack-accent);
background: var(--color-card); background: var(--snack-solid-bg);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-rule-strong);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-rule-strong);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-rule-strong);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset; box-shadow: var(--shadow-card), 0 12px 30px -10px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
} }
:global([data-theme="light"]) .snack-item { --snack-solid-bg: #fafafe; }
:global([data-theme="dark"]) .snack-item { :global([data-theme="dark"]) .snack-item {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent);
@@ -0,0 +1,622 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
let {
value = $bindable<string>('UTC'),
}: {
value: string;
} = $props();
// --- Catalog -----------------------------------------------------------
const timezones = $derived.by<string[]>(() => {
try {
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
if (typeof intl.supportedValuesOf === 'function') {
return intl.supportedValuesOf('timeZone');
}
} catch { /* fall through */ }
return ['UTC'];
});
const detectedTz = (() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
catch { return 'UTC'; }
})();
// --- Live clock --------------------------------------------------------
let now = $state(new Date());
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
function splitTz(tz: string): { region: string; city: string } {
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
const parts = tz.split('/');
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
const city = parts[parts.length - 1].replace(/_/g, ' ');
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
return { region, city };
}
function fmtTime(tz: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(now);
} catch { return '--:--:--'; }
}
function fmtDate(tz: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: tz,
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(now);
} catch { return ''; }
}
function fmtOffset(tz: string): string {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
timeZoneName: 'shortOffset',
}).formatToParts(now);
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
return off || 'UTC';
} catch { return ''; }
}
// --- Selected state ----------------------------------------------------
const selected = $derived.by(() => {
const s = splitTz(value || 'UTC');
return {
iana: value || 'UTC',
region: s.region,
city: s.city,
time: fmtTime(value || 'UTC'),
date: fmtDate(value || 'UTC'),
offset: fmtOffset(value || 'UTC'),
};
});
// --- Picker ------------------------------------------------------------
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
let panelEl = $state<HTMLDivElement | null>(null);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
if (!q) return timezones;
return timezones.filter(tz => tz.toLowerCase().includes(q));
});
// Group filtered tz list by region prefix for visual hierarchy.
interface Group { region: string; items: string[] }
const groups = $derived.by<Group[]>(() => {
const map = new Map<string, string[]>();
for (const tz of filtered) {
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
if (!map.has(region)) map.set(region, []);
map.get(region)!.push(tz);
}
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
return [...map.entries()]
.sort(([a], [b]) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
})
.map(([region, items]) => ({ region, items }));
});
// Flattened index for keyboard navigation.
const flat = $derived<string[]>(groups.flatMap(g => g.items));
function openPicker() {
open = true;
query = '';
highlightIdx = Math.max(0, flat.indexOf(value));
requestAnimationFrame(() => {
inputEl?.focus();
scrollToHighlight();
});
}
function closePicker() {
open = false;
query = '';
}
function selectTz(tz: string) {
value = tz;
closePicker();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closePicker(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
scrollToHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollToHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
}
}
function scrollToHighlight() {
requestAnimationFrame(() => {
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
});
}
$effect(() => { query; highlightIdx = 0; });
/**
* The panel is portalled to <body> to escape Card's overflow:hidden +
* backdrop-filter (which would otherwise clip and stacking-trap the
* dropdown). Outside-click is detected via the dedicated overlay div
* rather than a document listener, so we don't need a global handler.
*/
</script>
<div class="tz-root">
<!-- Selected card -->
<button
type="button"
class="tz-card"
class:tz-card-open={open}
onclick={() => (open ? closePicker() : openPicker())}
aria-haspopup="listbox"
aria-expanded={open}
>
<div class="tz-card-left">
<div class="tz-region">{selected.region}</div>
<div class="tz-city">{selected.city}</div>
<div class="tz-sub">
<span class="tz-iana">{selected.iana}</span>
{#if selected.date}
<span class="tz-dot">·</span>
<span class="tz-date">{selected.date}</span>
{/if}
</div>
</div>
<div class="tz-card-right">
<div class="tz-clock">{selected.time}</div>
<div class="tz-offset">{selected.offset}</div>
</div>
<span class="tz-chev" aria-hidden="true">
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
</span>
</button>
{#if open}
<div use:portal class="tz-portal-root">
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
.tz-root {
position: relative;
width: 100%;
max-width: 34rem;
}
/* ---- Selected card ------------------------------------------------ */
.tz-card {
position: relative;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.75rem 1rem 0.75rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 55%),
var(--color-background);
color: var(--color-foreground);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.tz-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.tz-card.tz-card-open {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.tz-card-left {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tz-region {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
}
.tz-city {
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-sub {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.tz-iana {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-dot { opacity: 0.5; }
.tz-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.tz-clock {
font-family: var(--font-mono);
font-size: 1.25rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--color-foreground);
line-height: 1;
/* Stable width so seconds ticker doesn't shift layout */
font-variant-numeric: tabular-nums;
}
.tz-offset {
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.1rem 0.375rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.tz-chev {
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
.tz-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.tz-overlay {
position: absolute;
inset: 0;
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
}
/* ---- Panel (centered modal palette) -------------------------------- */
.tz-panel {
pointer-events: auto;
position: absolute;
top: min(20vh, 120px);
left: 50%;
transform: translateX(-50%);
z-index: 1;
width: min(540px, 92vw);
max-height: min(60vh, 30rem);
background: var(--tz-solid-bg);
border: 1px solid var(--color-rule-strong, var(--color-border));
border-radius: 16px;
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
0 24px 48px -16px rgba(0, 0, 0, 0.55);
overflow: hidden;
display: flex;
flex-direction: column;
animation: tz-pop 0.15s ease-out;
--tz-solid-bg: #131520;
}
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
.tz-panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
opacity: 0.4;
}
@keyframes tz-pop {
from { opacity: 0; transform: translate(-50%, -3px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
position: relative;
z-index: 1;
}
.tz-search {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 0.85rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.tz-kbd {
font-size: 0.55rem;
font-family: var(--font-mono);
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.tz-quick {
display: flex;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.tz-quick-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 9999px;
background: var(--color-background);
font-size: 0.7rem;
color: var(--color-foreground);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.tz-quick-btn:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
}
.tz-quick-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-primary);
}
.tz-quick-label {
font-weight: 500;
}
.tz-quick-val {
font-family: var(--font-mono);
font-size: 0.65rem;
opacity: 0.7;
}
.tz-list {
overflow-y: auto;
/* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
padding: 0 0 0.25rem;
scrollbar-width: thin;
position: relative;
z-index: 1;
}
.tz-empty {
padding: 1rem;
text-align: center;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
.tz-group {
margin-bottom: 0.125rem;
}
.tz-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 0.75rem 0.25rem;
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--color-muted-foreground);
position: sticky;
top: 0;
background: var(--tz-solid-bg);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1;
}
.tz-group-count {
font-family: var(--font-mono);
opacity: 0.6;
}
.tz-opt {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.tz-opt.tz-opt-hl {
background: var(--color-muted);
}
.tz-opt.tz-opt-sel {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.tz-opt-city {
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt.tz-opt-sel .tz-opt-city {
color: var(--color-primary);
}
.tz-opt-iana {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt-offset {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
white-space: nowrap;
}
.tz-opt.tz-opt-hl .tz-opt-offset {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
</style>
+158 -13
View File
@@ -4,6 +4,10 @@
"tagline": "Service notifications" "tagline": "Service notifications"
}, },
"nav": { "nav": {
"sectionOverview": "Overview",
"sectionRouting": "Routing",
"sectionOperators": "Operators",
"sectionSystem": "System",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"providers": "Providers", "providers": "Providers",
"notificationTrackers": "Notif. Trackers", "notificationTrackers": "Notif. Trackers",
@@ -55,7 +59,8 @@
"passwordTooShort": "Password must be at least 8 characters", "passwordTooShort": "Password must be at least 8 characters",
"or": "or", "or": "or",
"loginFailed": "Login failed", "loginFailed": "Login failed",
"setupFailed": "Setup failed" "setupFailed": "Setup failed",
"backendUnreachable": "Cannot reach the server. Check that it's running and try again."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -78,6 +83,7 @@
"collectionRenamed": "collection renamed", "collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted", "collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed", "sharingChanged": "sharing changed",
"scheduledMessage": "scheduled message",
"actionSuccess": "action run", "actionSuccess": "action run",
"actionPartial": "action partial", "actionPartial": "action partial",
"actionFailed": "action failed", "actionFailed": "action failed",
@@ -101,11 +107,46 @@
"last14days": "Last 14 days", "last14days": "Last 14 days",
"event": "event", "event": "event",
"events": "events", "events": "events",
"noChartData": "No event data yet" "noChartData": "No event data yet",
"live": "Live",
"attention": "Attention",
"heroPrefix": "Tonight,",
"heroEmphasis": "everything",
"heroSuffix": "is flowing.",
"heroSummary": "{providers} providers listening, {armed} of {total} trackers armed, {throughput} events dispatched across {targets} targets in 24h.",
"throughput24h": "throughput · 24h",
"eventsShort": "events",
"armedShort": "armed",
"providersShort": "providers",
"targetsShort": "targets",
"trackersShort": "trackers",
"streamTitle": "Signal",
"streamEmphasis": "stream",
"eventsLabel": "events",
"onWatchTitle": "On",
"onWatchEmphasis": "watch",
"noProviders": "No providers yet.",
"addProvider": "Add provider",
"addProviderHint": "Connect a service to start tracking",
"pulseTitle": "Pulse",
"pulseEmphasis": "· last 14 days",
"pulseSub": "Events grouped by day",
"wiresTitle": "Active",
"wiresEmphasis": "wires",
"wiresSub": "routes",
"composeTitle": "Pick a source. Choose a channel.",
"composeEmphasis": "Compose the wire.",
"composeSub": "Walk from provider → tracker → template → target. Or paste a webhook URL and we'll infer the rest.",
"viewTrackers": "View trackers",
"newTracker": "New tracker",
"eventsTotal": "Events"
}, },
"providers": { "providers": {
"title": "Providers", "title": "Service",
"description": "Manage service provider connections", "titleEmphasis": "providers",
"description": "Connect to external services and webhooks. Each provider feeds events into trackers, which dispatch notifications across your channels.",
"typeSingular": "type",
"typePlural": "types",
"addProvider": "Add Provider", "addProvider": "Add Provider",
"cancel": "Cancel", "cancel": "Cancel",
"type": "Provider Type", "type": "Provider Type",
@@ -190,7 +231,10 @@
"cleared": "Payload history cleared" "cleared": "Payload history cleared"
}, },
"notificationTracker": { "notificationTracker": {
"title": "Notification Trackers", "title": "Notification",
"titleEmphasis": "trackers",
"armed": "armed",
"paused": "paused",
"description": "Monitor albums for changes", "description": "Monitor albums for changes",
"newTracker": "New Tracker", "newTracker": "New Tracker",
"cancel": "Cancel", "cancel": "Cancel",
@@ -248,7 +292,8 @@
"descending": "Descending", "descending": "Descending",
"quietHoursStart": "Quiet hours start", "quietHoursStart": "Quiet hours start",
"quietHoursEnd": "Quiet hours end", "quietHoursEnd": "Quiet hours end",
"batchDuration": "Batch duration (seconds)", "adaptiveMaxSkip": "Adaptive polling cap",
"adaptiveMaxSkipPlaceholder": "Off (blank or 0)",
"defaultTrackingConfig": "Default tracking config", "defaultTrackingConfig": "Default tracking config",
"defaultTemplateConfig": "Default template config", "defaultTemplateConfig": "Default template config",
"linkedTargets": "targets", "linkedTargets": "targets",
@@ -260,7 +305,14 @@
"testPeriodic": "Test periodic summary", "testPeriodic": "Test periodic summary",
"testScheduled": "Test scheduled assets", "testScheduled": "Test scheduled assets",
"testMemory": "Test memory / On This Day", "testMemory": "Test memory / On This Day",
"testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.",
"checkingLinks": "Checking links...", "checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
"linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.",
"missingLinksTitle": "Albums Missing Public Links", "missingLinksTitle": "Albums Missing Public Links",
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.", "missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
"expired": "Expired", "expired": "Expired",
@@ -294,6 +346,11 @@
"albumDeleted": "Album deleted" "albumDeleted": "Album deleted"
}, },
"targets": { "targets": {
"titleEmphasis": "channel",
"titleEmphasisAll": "channels",
"receiver": "receiver",
"receivers": "receivers",
"channelsCount": "channels",
"title": "Targets", "title": "Targets",
"description": "Notification delivery destinations", "description": "Notification delivery destinations",
"descTelegram": "Telegram chat destinations for notifications", "descTelegram": "Telegram chat destinations for notifications",
@@ -362,6 +419,8 @@
"receiverDisabled": "Receiver disabled" "receiverDisabled": "Receiver disabled"
}, },
"users": { "users": {
"titleEmphasis": "& access",
"countLabel": "users",
"title": "Users", "title": "Users",
"description": "Manage user accounts (admin only)", "description": "Manage user accounts (admin only)",
"addUser": "Add User", "addUser": "Add User",
@@ -379,6 +438,8 @@
"noUsers": "No users found" "noUsers": "No users found"
}, },
"telegramBot": { "telegramBot": {
"titleEmphasis": "telegram",
"countLabel": "bots",
"title": "Telegram Bots", "title": "Telegram Bots",
"description": "Register and manage Telegram bots", "description": "Register and manage Telegram bots",
"addBot": "Add Bot", "addBot": "Add Bot",
@@ -431,6 +492,8 @@
"webhookRegistered": "Webhook registered", "webhookRegistered": "Webhook registered",
"webhookUnregistered": "Webhook unregistered", "webhookUnregistered": "Webhook unregistered",
"updateMode": "Update mode", "updateMode": "Update mode",
"none": "None",
"noneActive": "Listener disabled",
"polling": "Polling", "polling": "Polling",
"webhook": "Webhook", "webhook": "Webhook",
"webhookStatus": "Webhook status", "webhookStatus": "Webhook status",
@@ -453,6 +516,8 @@
"webhookFailed": "Failed to register webhook" "webhookFailed": "Failed to register webhook"
}, },
"trackingConfig": { "trackingConfig": {
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Tracking Configs", "title": "Tracking Configs",
"description": "Define what events and assets to react to", "description": "Define what events and assets to react to",
"newConfig": "New Config", "newConfig": "New Config",
@@ -548,11 +613,21 @@
"renamed": "renamed", "renamed": "renamed",
"deleted": "deleted", "deleted": "deleted",
"providerType": "Provider Type", "providerType": "Provider Type",
"sortRandom": "Random" "sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"previewTemplate": "Preview template",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
"quietHoursZero": "Quiet period is 0 minutes — adjust times",
"nextDay": "next day"
}, },
"templateConfig": { "templateConfig": {
"titleEmphasis": "templates",
"countLabel": "templates",
"title": "Template Configs", "title": "Template Configs",
"description": "Define how notification messages are formatted", "description": "Define how notification messages are formatted",
"language": "Language",
"providerType": "Service Provider Type", "providerType": "Service Provider Type",
"newConfig": "New Config", "newConfig": "New Config",
"name": "Name", "name": "Name",
@@ -594,7 +669,14 @@
"confirmDelete": "Delete this template config?", "confirmDelete": "Delete this template config?",
"invalidFormat": "Invalid format string", "invalidFormat": "Invalid format string",
"filterSlots": "Filter slots...", "filterSlots": "Filter slots...",
"slots": "slots" "slots": "slots",
"resetToDefault": "Reset to default",
"resetAllToDefaults": "Reset all to defaults",
"resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.",
"resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.",
"resetNoDefault": "No shipped default for this slot.",
"resetApplied": "Reset to default (not saved yet — click Save to persist)",
"deepLinkNoConfig": "No template config found for this provider. Create one first."
}, },
"templateVars": { "templateVars": {
"message_assets_added": { "message_assets_added": {
@@ -663,6 +745,7 @@
"album_shared": "Whether album is shared" "album_shared": "Whether album is shared"
}, },
"settings": { "settings": {
"titleEmphasis": "options",
"title": "Settings", "title": "Settings",
"description": "Global application settings", "description": "Global application settings",
"general": "General", "general": "General",
@@ -671,13 +754,36 @@
"telegram": "Telegram", "telegram": "Telegram",
"webhookSecret": "Webhook Secret", "webhookSecret": "Webhook Secret",
"webhookSecretHint": "Secret token to verify webhook requests from Telegram", "webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)", "cacheTtl": "URL Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", "cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
"cacheMaxEntries": "Cache Max Entries",
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
"cacheStats": "Cache contents",
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
"cacheStatsUrl": "URL cache",
"cacheStatsAsset": "Asset cache",
"cacheStatsEntries": "entries",
"cacheStatsEmpty": "empty",
"cacheStatsOldest": "oldest",
"cacheStatsNewest": "newest",
"clearCache": "Clear Media Cache",
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
"clearCacheConfirmTitle": "Clear Telegram cache?",
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
"clearCacheConfirmBtn": "Clear cache",
"clearCacheDone": "Telegram cache cleared",
"timezone": "Timezone", "timezone": "Timezone",
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.", "timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
"locales": "Template Languages", "locales": "Template Languages",
"supportedLocales": "Supported Locales", "supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)", "supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
"logging": "Logging",
"logLevel": "Log Level",
"logLevelHint": "Root log level for the server. Raise to DEBUG while investigating; keep at INFO in production. WARNING/ERROR hide per-command progress lines.",
"logFormat": "Log Format",
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
"logLevels": "Per-Module Overrides",
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Settings saved" "saved": "Settings saved"
}, },
"hints": { "hints": {
@@ -688,9 +794,12 @@
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.", "quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.", "favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.", "maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.", "periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00", "times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.", "albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
"minRating": "Only include assets with at least this star rating (0 = no filter).", "minRating": "Only include assets with at least this star rating (0 = no filter).",
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.", "eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
"assetFormatting": "How individual assets are formatted within notification messages.", "assetFormatting": "How individual assets are formatted within notification messages.",
@@ -704,7 +813,7 @@
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.", "trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
"templateConfig": "Controls the message format. Uses default templates if not set.", "templateConfig": "Controls the message format. Uses default templates if not set.",
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.", "scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.", "adaptiveMaxSkip": "Reduces polling when the tracker is idle, to save load on the upstream server. Leave blank or set to 0 for snappy notifications — every tick runs at the configured interval. Set to 2 to allow up to 2× slower polling after ~5 min of silence, or 4 for up to 4× slower polling after ~15 min. Activity resets back to the base rate immediately.",
"defaultTrackingConfig": "Applied to all linked targets unless overridden per target.", "defaultTrackingConfig": "Applied to all linked targets unless overridden per target.",
"defaultTemplateConfig": "Applied to all linked targets unless overridden per target.", "defaultTemplateConfig": "Applied to all linked targets unless overridden per target.",
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).", "defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
@@ -713,6 +822,8 @@
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit." "rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
}, },
"matrixBot": { "matrixBot": {
"titleEmphasis": "matrix",
"countLabel": "bots",
"title": "Matrix Bots", "title": "Matrix Bots",
"description": "Matrix homeserver connections for room notifications", "description": "Matrix homeserver connections for room notifications",
"addBot": "Add Matrix Bot", "addBot": "Add Matrix Bot",
@@ -729,6 +840,8 @@
"operationFailed": "Operation failed" "operationFailed": "Operation failed"
}, },
"emailBot": { "emailBot": {
"titleEmphasis": "email",
"countLabel": "accounts",
"title": "Email Bots", "title": "Email Bots",
"description": "SMTP email senders for notifications", "description": "SMTP email senders for notifications",
"addBot": "Add Email Bot", "addBot": "Add Email Bot",
@@ -748,6 +861,8 @@
"operationFailed": "Operation failed" "operationFailed": "Operation failed"
}, },
"cmdTemplateConfig": { "cmdTemplateConfig": {
"titleEmphasis": "templates",
"countLabel": "templates",
"title": "Command Templates", "title": "Command Templates",
"description": "Customize command response messages with Jinja2 templates", "description": "Customize command response messages with Jinja2 templates",
"newConfig": "New Config", "newConfig": "New Config",
@@ -760,6 +875,8 @@
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response." "commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
}, },
"commandConfig": { "commandConfig": {
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Command Configs", "title": "Command Configs",
"description": "Define command settings for Telegram bot interactions", "description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config", "newConfig": "New Config",
@@ -782,6 +899,7 @@
"noTemplate": "Default (hardcoded)" "noTemplate": "Default (hardcoded)"
}, },
"commandTracker": { "commandTracker": {
"titleEmphasis": "trackers",
"title": "Command Trackers", "title": "Command Trackers",
"description": "Manage command trackers and their listeners", "description": "Manage command trackers and their listeners",
"newTracker": "New Tracker", "newTracker": "New Tracker",
@@ -813,6 +931,30 @@
"showDetails": "Show details", "showDetails": "Show details",
"hideDetails": "Hide details" "hideDetails": "Hide details"
}, },
"timezone": {
"searchPlaceholder": "Search cities or IANA codes…",
"detect": "Detect",
"utc": "UTC",
"noMatches": "No timezones match"
},
"locales": {
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"customPlaceholder": "or de-CH",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary",
"shipped": "Built-in",
"shippedHint": "Default notification & command templates ship for this language.",
"makePrimary": "Make primary",
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove",
"removeLast": "At least one language is required",
"reorder": "Drag to reorder",
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
},
"snack": { "snack": {
"eventsCleared": "{count} event(s) cleared", "eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved", "providerSaved": "Provider saved",
@@ -1041,6 +1183,8 @@
"close": "close" "close": "close"
}, },
"actions": { "actions": {
"titleEmphasis": "automations",
"countLabel": "actions",
"title": "Actions", "title": "Actions",
"description": "Scheduled mutations on external services", "description": "Scheduled mutations on external services",
"addAction": "Add Action", "addAction": "Add Action",
@@ -1098,6 +1242,7 @@
"triggerScheduled": "scheduled" "triggerScheduled": "scheduled"
}, },
"backup": { "backup": {
"titleEmphasis": "& restore",
"title": "Backup & Restore", "title": "Backup & Restore",
"description": "Export and import your configuration, or set up automatic backups", "description": "Export and import your configuration, or set up automatic backups",
"export": "Export Configuration", "export": "Export Configuration",
+157 -12
View File
@@ -4,6 +4,10 @@
"tagline": "Уведомления о сервисах" "tagline": "Уведомления о сервисах"
}, },
"nav": { "nav": {
"sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация",
"sectionOperators": "Операторы",
"sectionSystem": "Система",
"dashboard": "Главная", "dashboard": "Главная",
"providers": "Провайдеры", "providers": "Провайдеры",
"notificationTrackers": "Трекеры увед.", "notificationTrackers": "Трекеры увед.",
@@ -55,7 +59,8 @@
"passwordTooShort": "Пароль должен быть не менее 8 символов", "passwordTooShort": "Пароль должен быть не менее 8 символов",
"or": "или", "or": "или",
"loginFailed": "Ошибка входа", "loginFailed": "Ошибка входа",
"setupFailed": "Ошибка настройки" "setupFailed": "Ошибка настройки",
"backendUnreachable": "Не удалось подключиться к серверу. Убедитесь, что он запущен, и повторите попытку."
}, },
"dashboard": { "dashboard": {
"title": "Главная", "title": "Главная",
@@ -78,6 +83,7 @@
"collectionRenamed": "альбом переименован", "collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён", "collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа", "sharingChanged": "изменение доступа",
"scheduledMessage": "запланированное сообщение",
"actionSuccess": "действие выполнено", "actionSuccess": "действие выполнено",
"actionPartial": "действие частично", "actionPartial": "действие частично",
"actionFailed": "действие провалено", "actionFailed": "действие провалено",
@@ -101,11 +107,46 @@
"last14days": "Последние 14 дней", "last14days": "Последние 14 дней",
"event": "событие", "event": "событие",
"events": "событий", "events": "событий",
"noChartData": "Нет данных о событиях" "noChartData": "Нет данных о событиях",
"live": "В эфире",
"attention": "Внимание",
"heroPrefix": "Сегодня",
"heroEmphasis": "всё",
"heroSuffix": "идёт по плану.",
"heroSummary": "{providers} провайдеров на связи, {armed} из {total} трекеров активны, {throughput} событий доставлено в {targets} каналов за сутки.",
"throughput24h": "пропускная способность · 24ч",
"eventsShort": "событий",
"armedShort": "активны",
"providersShort": "провайдеров",
"targetsShort": "каналов",
"trackersShort": "трекеров",
"streamTitle": "Поток",
"streamEmphasis": "сигналов",
"eventsLabel": "событий",
"onWatchTitle": "На",
"onWatchEmphasis": "слежении",
"noProviders": "Пока нет провайдеров.",
"addProvider": "Добавить",
"addProviderHint": "Подключите сервис, чтобы начать слежение",
"pulseTitle": "Пульс",
"pulseEmphasis": "· 14 дней",
"pulseSub": "События по дням",
"wiresTitle": "Активные",
"wiresEmphasis": "линии",
"wiresSub": "маршрутов",
"composeTitle": "Выберите источник, выберите канал.",
"composeEmphasis": "Свяжите.",
"composeSub": "Проведите путь от провайдера → трекер → шаблон → цель. Или вставьте webhook URL — остальное мы определим сами.",
"viewTrackers": "К трекерам",
"newTracker": "Новый трекер",
"eventsTotal": "Событий"
}, },
"providers": { "providers": {
"title": "Провайдеры", "title": "Сервисные",
"description": "Управление подключениями к сервисам", "titleEmphasis": "провайдеры",
"description": "Подключения к внешним сервисам и вебхукам. Каждый провайдер кормит трекеры событиями, которые рассылаются по вашим каналам.",
"typeSingular": "тип",
"typePlural": "типов",
"addProvider": "Добавить провайдер", "addProvider": "Добавить провайдер",
"cancel": "Отмена", "cancel": "Отмена",
"type": "Тип провайдера", "type": "Тип провайдера",
@@ -190,6 +231,9 @@
"cleared": "История запросов очищена" "cleared": "История запросов очищена"
}, },
"notificationTracker": { "notificationTracker": {
"titleEmphasis": "трекеры",
"armed": "активны",
"paused": "на паузе",
"title": "Трекеры уведомлений", "title": "Трекеры уведомлений",
"description": "Отслеживание изменений в альбомах", "description": "Отслеживание изменений в альбомах",
"newTracker": "Новый трекер", "newTracker": "Новый трекер",
@@ -248,7 +292,8 @@
"descending": "По убыванию", "descending": "По убыванию",
"quietHoursStart": "Тихие часы начало", "quietHoursStart": "Тихие часы начало",
"quietHoursEnd": "Тихие часы конец", "quietHoursEnd": "Тихие часы конец",
"batchDuration": "Длительность пакета (секунды)", "adaptiveMaxSkip": "Предел адаптивного опроса",
"adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)",
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию", "defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию", "defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
"linkedTargets": "получатели", "linkedTargets": "получатели",
@@ -260,7 +305,14 @@
"testPeriodic": "Тест периодической сводки", "testPeriodic": "Тест периодической сводки",
"testScheduled": "Тест запланированных фото", "testScheduled": "Тест запланированных фото",
"testMemory": "Тест воспоминаний", "testMemory": "Тест воспоминаний",
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
"checkingLinks": "Проверка ссылок...", "checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
"missingLinksTitle": "Альбомы без публичных ссылок", "missingLinksTitle": "Альбомы без публичных ссылок",
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.", "missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
"expired": "Истёк", "expired": "Истёк",
@@ -294,6 +346,11 @@
"albumDeleted": "Альбом удалён" "albumDeleted": "Альбом удалён"
}, },
"targets": { "targets": {
"titleEmphasis": "канал",
"titleEmphasisAll": "каналы",
"receiver": "получатель",
"receivers": "получателей",
"channelsCount": "каналов",
"title": "Получатели", "title": "Получатели",
"description": "Адреса доставки уведомлений", "description": "Адреса доставки уведомлений",
"descTelegram": "Чаты Telegram для доставки уведомлений", "descTelegram": "Чаты Telegram для доставки уведомлений",
@@ -362,6 +419,8 @@
"receiverDisabled": "Получатель отключён" "receiverDisabled": "Получатель отключён"
}, },
"users": { "users": {
"titleEmphasis": "и доступ",
"countLabel": "пользователей",
"title": "Пользователи", "title": "Пользователи",
"description": "Управление аккаунтами (только админ)", "description": "Управление аккаунтами (только админ)",
"addUser": "Добавить пользователя", "addUser": "Добавить пользователя",
@@ -379,6 +438,8 @@
"noUsers": "Пользователи не найдены" "noUsers": "Пользователи не найдены"
}, },
"telegramBot": { "telegramBot": {
"titleEmphasis": "telegram",
"countLabel": "ботов",
"title": "Telegram боты", "title": "Telegram боты",
"description": "Регистрация и управление Telegram ботами", "description": "Регистрация и управление Telegram ботами",
"addBot": "Добавить бота", "addBot": "Добавить бота",
@@ -431,6 +492,8 @@
"webhookRegistered": "Вебхук зарегистрирован", "webhookRegistered": "Вебхук зарегистрирован",
"webhookUnregistered": "Вебхук удалён", "webhookUnregistered": "Вебхук удалён",
"updateMode": "Режим обновлений", "updateMode": "Режим обновлений",
"none": "Откл.",
"noneActive": "Приём обновлений отключён",
"polling": "Опрос", "polling": "Опрос",
"webhook": "Вебхук", "webhook": "Вебхук",
"webhookStatus": "Статус вебхука", "webhookStatus": "Статус вебхука",
@@ -453,6 +516,8 @@
"webhookFailed": "Не удалось зарегистрировать webhook" "webhookFailed": "Не удалось зарегистрировать webhook"
}, },
"trackingConfig": { "trackingConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации отслеживания", "title": "Конфигурации отслеживания",
"description": "Определите, на какие события и файлы реагировать", "description": "Определите, на какие события и файлы реагировать",
"newConfig": "Новая конфигурация", "newConfig": "Новая конфигурация",
@@ -548,11 +613,21 @@
"renamed": "переименование", "renamed": "переименование",
"deleted": "удалён", "deleted": "удалён",
"providerType": "Тип провайдера", "providerType": "Тип провайдера",
"sortRandom": "Случайный" "sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
"quietHoursZero": "Тихий период 0 минут — скорректируйте время",
"nextDay": "след. день"
}, },
"templateConfig": { "templateConfig": {
"titleEmphasis": "шаблоны",
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов", "title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений", "description": "Определите формат уведомлений",
"language": "Язык",
"providerType": "Тип сервис-провайдера", "providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация", "newConfig": "Новая конфигурация",
"name": "Название", "name": "Название",
@@ -594,7 +669,14 @@
"confirmDelete": "Удалить эту конфигурацию шаблона?", "confirmDelete": "Удалить эту конфигурацию шаблона?",
"invalidFormat": "Некорректная строка формата", "invalidFormat": "Некорректная строка формата",
"filterSlots": "Фильтр слотов...", "filterSlots": "Фильтр слотов...",
"slots": "слотов" "slots": "слотов",
"resetToDefault": "Сбросить к умолчанию",
"resetAllToDefaults": "Сбросить все к умолчаниям",
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
}, },
"templateVars": { "templateVars": {
"message_assets_added": { "message_assets_added": {
@@ -663,6 +745,7 @@
"album_shared": "Общий альбом" "album_shared": "Общий альбом"
}, },
"settings": { "settings": {
"titleEmphasis": "параметры",
"title": "Настройки", "title": "Настройки",
"description": "Глобальные настройки приложения", "description": "Глобальные настройки приложения",
"general": "Общие", "general": "Общие",
@@ -671,13 +754,36 @@
"telegram": "Telegram", "telegram": "Telegram",
"webhookSecret": "Секрет вебхука", "webhookSecret": "Секрет вебхука",
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)", "cacheTtl": "TTL URL-кэша (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", "cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
"cacheMaxEntries": "Макс. записей в кэше",
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
"cacheStats": "Содержимое кэша",
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
"cacheStatsUrl": "Кэш URL",
"cacheStatsAsset": "Кэш ассетов",
"cacheStatsEntries": "записей",
"cacheStatsEmpty": "пусто",
"cacheStatsOldest": "самая старая",
"cacheStatsNewest": "самая свежая",
"clearCache": "Очистить кэш медиа",
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
"clearCacheConfirmBtn": "Очистить кэш",
"clearCacheDone": "Кэш Telegram очищен",
"timezone": "Часовой пояс", "timezone": "Часовой пояс",
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.", "timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов", "locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали", "supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)", "supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
"logging": "Логирование",
"logLevel": "Уровень логов",
"logLevelHint": "Уровень логирования сервера. Поднимайте до DEBUG при отладке; оставляйте INFO в продакшене. WARNING/ERROR скрывают пошаговые строки по командам.",
"logFormat": "Формат логов",
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Настройки сохранены" "saved": "Настройки сохранены"
}, },
"hints": { "hints": {
@@ -688,9 +794,12 @@
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.", "quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", "periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00", "times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.", "albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).", "minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.", "eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.", "assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
@@ -704,7 +813,7 @@
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.", "trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.", "templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.", "scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.", "adaptiveMaxSkip": "Снижает частоту опроса, когда отслеживание простаивает — уменьшает нагрузку на сервер-источник. Оставьте пустым или 0, чтобы уведомления приходили без задержки: каждый тик выполняется с заданным интервалом. Значение 2 позволит замедлять опрос до 2× после ~5 мин простоя, а 4 — до 4× после ~15 мин. Любая активность сразу возвращает базовую частоту.",
"defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.", "defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
"defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.", "defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).", "defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
@@ -713,6 +822,8 @@
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений." "rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
}, },
"matrixBot": { "matrixBot": {
"titleEmphasis": "matrix",
"countLabel": "ботов",
"title": "Matrix боты", "title": "Matrix боты",
"description": "Подключения к Matrix серверам для уведомлений в комнаты", "description": "Подключения к Matrix серверам для уведомлений в комнаты",
"addBot": "Добавить Matrix бот", "addBot": "Добавить Matrix бот",
@@ -729,6 +840,8 @@
"operationFailed": "Операция не удалась" "operationFailed": "Операция не удалась"
}, },
"emailBot": { "emailBot": {
"titleEmphasis": "email",
"countLabel": "учётных записей",
"title": "Email боты", "title": "Email боты",
"description": "SMTP отправители для уведомлений по email", "description": "SMTP отправители для уведомлений по email",
"addBot": "Добавить Email бот", "addBot": "Добавить Email бот",
@@ -748,6 +861,8 @@
"operationFailed": "Операция не удалась" "operationFailed": "Операция не удалась"
}, },
"cmdTemplateConfig": { "cmdTemplateConfig": {
"titleEmphasis": "шаблоны",
"countLabel": "шаблонов",
"title": "Шаблоны команд", "title": "Шаблоны команд",
"description": "Настройте ответы команд с помощью Jinja2 шаблонов", "description": "Настройте ответы команд с помощью Jinja2 шаблонов",
"newConfig": "Новый шаблон", "newConfig": "Новый шаблон",
@@ -760,6 +875,8 @@
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию." "commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
}, },
"commandConfig": { "commandConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд", "title": "Конфигурации команд",
"description": "Настройки команд для взаимодействия с Telegram-ботами", "description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация", "newConfig": "Новая конфигурация",
@@ -782,6 +899,7 @@
"noTemplate": "По умолчанию (встроенный)" "noTemplate": "По умолчанию (встроенный)"
}, },
"commandTracker": { "commandTracker": {
"titleEmphasis": "трекеры",
"title": "Трекеры команд", "title": "Трекеры команд",
"description": "Управление трекерами команд и их слушателями", "description": "Управление трекерами команд и их слушателями",
"newTracker": "Новый трекер", "newTracker": "Новый трекер",
@@ -813,6 +931,30 @@
"showDetails": "Показать детали", "showDetails": "Показать детали",
"hideDetails": "Скрыть детали" "hideDetails": "Скрыть детали"
}, },
"timezone": {
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
"detect": "Определить",
"utc": "UTC",
"noMatches": "Нет совпадений"
},
"locales": {
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
"shipped": "Встроенный",
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
"makePrimary": "Сделать основным",
"moveUp": "Выше",
"moveDown": "Ниже",
"remove": "Удалить",
"removeLast": "Должен быть хотя бы один язык",
"reorder": "Перетащите для изменения порядка",
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
},
"snack": { "snack": {
"eventsCleared": "Очищено событий: {count}", "eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён", "providerSaved": "Провайдер сохранён",
@@ -1041,6 +1183,8 @@
"close": "закрыть" "close": "закрыть"
}, },
"actions": { "actions": {
"titleEmphasis": "автоматизации",
"countLabel": "действий",
"title": "Действия", "title": "Действия",
"description": "Запланированные операции над внешними сервисами", "description": "Запланированные операции над внешними сервисами",
"addAction": "Добавить действие", "addAction": "Добавить действие",
@@ -1098,6 +1242,7 @@
"triggerScheduled": "по расписанию" "triggerScheduled": "по расписанию"
}, },
"backup": { "backup": {
"titleEmphasis": "и восстановление",
"title": "Резервное копирование", "title": "Резервное копирование",
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов", "description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
"export": "Экспорт конфигурации", "export": "Экспорт конфигурации",
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared locale catalog used by LocaleSelector (settings) and the
* template editors (notification & command). Single source of truth so
* native names and metadata stay consistent across pickers.
*/
export interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
export const LOCALE_CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
export function getLocaleMeta(code: string): LocaleMeta {
return LOCALE_CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Svelte action that re-parents a node to document.body (or any selector).
*
* Use this for popups / dropdowns / tooltips that rely on
* `position: fixed` positioning. Any ancestor with `backdrop-filter`,
* `transform`, `filter`, `perspective`, `contain: paint`, or
* `will-change: transform` becomes the containing block for fixed
* descendants — which silently breaks viewport-relative positioning.
*
* Portalling sidesteps that by detaching the node from the component
* tree and appending it to a target outside any such ancestor.
*
* Usage:
* <div use:portal>...</div> // → document.body
* <div use:portal={'#root'}>...</div> // → custom selector
*/
export type PortalTarget = string | HTMLElement;
export function portal(node: HTMLElement, target: PortalTarget = 'body') {
function attach(t: PortalTarget) {
const el = typeof t === 'string' ? document.querySelector(t) : t;
if (el instanceof HTMLElement) el.appendChild(node);
}
attach(target);
return {
update(newTarget: PortalTarget) {
attach(newTarget);
},
destroy() {
node.parentNode?.removeChild(node);
},
};
}
+23 -14
View File
@@ -1,5 +1,12 @@
import type { ProviderDescriptor } from './types'; import type { ProviderDescriptor } from './types';
/**
* Today's date in ISO (YYYY-MM-DD) — used as the default for
* `periodic_start_date` so new configs anchor to "today" rather than a
* hardcoded date that gets further into the past on every release.
*/
const todayIso = (): string => new Date().toISOString().slice(0, 10);
export const immichDescriptor: ProviderDescriptor = { export const immichDescriptor: ProviderDescriptor = {
type: 'immich', type: 'immich',
defaultName: 'Immich', defaultName: 'Immich',
@@ -48,7 +55,7 @@ export const immichDescriptor: ProviderDescriptor = {
], ],
extraTrackingFields: [ extraTrackingFields: [
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' }, { key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' }, { key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' }, { key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
], ],
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary', key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
enabledField: 'periodic_enabled', enabledDefault: false, enabledField: 'periodic_enabled', enabledDefault: false,
fields: [ fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 }, { key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input { key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input { key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
], ],
}, },
{ {
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets', key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false, enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [ fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, { key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' }, { key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' }, { key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, { key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' }, { key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
@@ -79,21 +86,21 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode', key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
enabledField: 'memory_enabled', enabledDefault: false, enabledField: 'memory_enabled', enabledDefault: false,
fields: [ fields: [
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, { key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' }, { key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 }, { key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, { key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 }, { key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' }, { key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
], ],
}, },
{ {
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours', key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false, enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [ fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' }, { key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' }, { key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
], ],
}, },
], ],
@@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = {
const warnings: { id: string; name: string; issue: string }[] = []; const warnings: { id: string; name: string; issue: string }[] = [];
// Run shared-link checks in parallel with a concurrency cap so a large // Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds. // album set doesn't stall the save button for seconds. Cap of 6 keeps
// the save dialog responsive for users with 50+ albums while staying
// well under typical Immich per-IP rate limits.
const CONCURRENCY = 6; const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> { async function checkOne(albumId: string): Promise<void> {
try { try {
+5 -2
View File
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
*/ */
export function buildTrackingFormDefaults(): Record<string, any> { export function buildTrackingFormDefaults(): Record<string, any> {
const defaults: Record<string, any> = {}; const defaults: Record<string, any> = {};
// `defaultValue` may be a function (for time-sensitive defaults like
// today's date) so the computed value is fresh each time the form resets.
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
for (const desc of REGISTRY.values()) { for (const desc of REGISTRY.values()) {
for (const field of desc.eventFields) { for (const field of desc.eventFields) {
defaults[field.key] = field.default; defaults[field.key] = field.default;
} }
for (const extra of desc.extraTrackingFields ?? []) { for (const extra of desc.extraTrackingFields ?? []) {
defaults[extra.key] = extra.defaultValue ?? ''; defaults[extra.key] = resolve(extra.defaultValue) ?? '';
} }
for (const section of desc.featureSections ?? []) { for (const section of desc.featureSections ?? []) {
defaults[section.enabledField] = section.enabledDefault; defaults[section.enabledField] = section.enabledDefault;
for (const f of section.fields) { for (const f of section.fields) {
defaults[f.key] = f.defaultValue ?? ''; defaults[f.key] = resolve(f.defaultValue) ?? '';
} }
for (const cb of section.checkboxes ?? []) { for (const cb of section.checkboxes ?? []) {
defaults[cb.key] = cb.default; defaults[cb.key] = cb.default;
+19 -2
View File
@@ -60,14 +60,31 @@ export interface EventTrackingField {
export interface ExtraTrackingField { export interface ExtraTrackingField {
key: string; key: string;
label: string; label: string;
type: 'number' | 'grid-select' | 'toggle'; /**
* Control kind:
* - `number` — numeric spinner
* - `grid-select` — icon-grid chooser (requires `gridItems`)
* - `toggle` — on/off switch
* - `date` — HTML date picker (YYYY-MM-DD)
* - `time` — HTML time picker (HH:MM)
* - `time-list` — comma-separated HH:MM list, validated on blur
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */ /** Grid-select item source function name from grid-items.ts. */
gridItems?: string; gridItems?: string;
gridColumns?: number; gridColumns?: number;
hint?: string; hint?: string;
/** Inline helper text rendered under the input (not a tooltip). */
inlineHelp?: string;
min?: number; min?: number;
max?: number; max?: number;
defaultValue?: string | number | boolean; /** For time-list: show live validation + auto-normalize on blur. */
validateFormat?: boolean;
/**
* Default value. Can be a function for dynamic values (e.g. today's date)
* evaluated each time the form is reset.
*/
defaultValue?: string | number | boolean | (() => string | number | boolean);
} }
/** A feature section like periodic summary, scheduled assets, memory mode. */ /** A feature section like periodic summary, scheduled assets, memory mode. */
@@ -0,0 +1,35 @@
/**
* Page-scoped primary action for the global topbar CTA.
*
* Each route declares its own primary action ("Add Provider",
* "New Tracker", etc.) by calling `topbarAction.set({...})`
* inside its `onMount`, and clears it on teardown. The layout
* reads `topbarAction.current` and renders the button.
*
* Falls back to the default "New tracker" CTA when no action is
* registered (set by the layout itself).
*/
export interface TopbarAction {
/** Visible label, e.g. "Add Provider". */
label: string;
/** Optional href — renders as <a>. Mutually exclusive with onclick. */
href?: string;
/** Optional click handler — renders as <button>. */
onclick?: () => void;
/** Optional MDI/NavIcon name for the leading glyph (default: mdiPlus). */
icon?: string;
}
let action = $state<TopbarAction | null>(null);
export const topbarAction = {
get current(): TopbarAction | null {
return action;
},
set(next: TopbarAction | null) {
action = next;
},
clear() {
action = null;
},
};
+1 -1
View File
@@ -80,7 +80,7 @@ export interface Tracker {
provider_id: number; provider_id: number;
collection_ids: string[]; collection_ids: string[];
scan_interval: number; scan_interval: number;
batch_duration: number; adaptive_max_skip: number | null;
default_tracking_config_id: number | null; default_tracking_config_id: number | null;
default_template_config_id: number | null; default_template_config_id: number | null;
enabled: boolean; enabled: boolean;
+512 -201
View File
@@ -7,10 +7,10 @@
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte'; import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale, type Locale } from '$lib/i18n'; import { t, getLocale, setLocale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte'; import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import NavIcon from '$lib/components/NavIcon.svelte';
import Snackbar from '$lib/components/Snackbar.svelte'; import Snackbar from '$lib/components/Snackbar.svelte';
import SearchPalette from '$lib/components/SearchPalette.svelte'; import SearchPalette from '$lib/components/SearchPalette.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -21,6 +21,7 @@
matrixBotsCache, targetsCache, matrixBotsCache, targetsCache,
} from '$lib/stores/caches.svelte'; } from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { providerDefaultIcon } from '$lib/grid-items'; import { providerDefaultIcon } from '$lib/grid-items';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -201,6 +202,19 @@
: baseNavEntries : baseNavEntries
); );
/**
* Section labels above groups of nav entries — emitted in the template
* before the entry whose key matches a map below. Mirrors the Aurora
* mockup's "Overview / Routing / Operators / System" section rhythm
* without breaking the existing collapsible-group structure.
*/
const SECTION_BREAKS: Record<string, string> = {
'nav.dashboard': 'nav.sectionOverview',
'nav.notification': 'nav.sectionRouting',
'nav.bots': 'nav.sectionOperators',
'nav.settings': 'nav.sectionSystem',
};
// Track which groups are expanded (persisted in localStorage) // Track which groups are expanded (persisted in localStorage)
let expandedGroups = $state<Record<string, boolean>>({}); let expandedGroups = $state<Record<string, boolean>>({});
@@ -218,32 +232,30 @@
}); });
} }
// Mobile: flatten nav for bottom bar (first 4 + "More" button) // Mobile bottom-nav derives its 4 primary slots from baseNavEntries by key
const mobileNavItems = $derived<NavItem[]>([ // lookup. Adding a new top-level nav entry doesn't break this list, and
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' }, // renaming a key fails loudly via the assertion below — keeping desktop
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' }, // and mobile nav structure in sync without manual duplication.
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' }, const MOBILE_PRIMARY_KEYS = ['nav.dashboard', 'nav.notification', 'nav.commands', 'nav.targets'] as const;
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' }, const mobileNavItems = $derived<NavItem[]>(
]); MOBILE_PRIMARY_KEYS.map(key => {
const entry = baseNavEntries.find(e => e.key === key);
// "More" panel items — everything not in the bottom bar if (!entry) return null;
const mobileMoreItems = $derived<NavItem[]>([ return isGroup(entry)
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' }, ? { href: entry.children[0]?.href ?? '/', key: entry.key, icon: entry.icon }
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' }, : entry;
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' }, }).filter((x): x is NavItem => x !== null)
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' }, );
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
...(auth.isAdmin ? [
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
] : []),
]);
// "More" panel mirrors the full desktop sidebar tree so every subnode is
// reachable on mobile (previously it was a flat hand-picked list that
// hid all target types, bot channels, and several nested pages).
let mobileMoreOpen = $state(false); let mobileMoreOpen = $state(false);
function closeMobileMore() {
mobileMoreOpen = false;
}
const isAuthPage = $derived( const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup' page.url.pathname === '/login' || page.url.pathname === '/setup'
); );
@@ -355,31 +367,34 @@
</div> </div>
</div> </div>
{:else if auth.user} {:else if auth.user}
<div class="flex h-screen"> <div class="app-shell">
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden" class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
style="background: var(--color-sidebar); border-right: 1px solid var(--color-border); transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);"
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);"> <div class="sidebar-header flex items-center {collapsed ? 'justify-center p-3' : 'justify-between px-5 py-5'}">
{#if !collapsed} {#if !collapsed}
<div class="animate-fade-slide-in"> <div class="animate-fade-slide-in flex items-center gap-3">
<h1 class="text-base font-semibold tracking-tight flex items-center gap-1.5" style="color: var(--color-foreground);"> <div class="brand-orb"></div>
{#if globalProviderFilter.provider} <div class="brand-text">
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span> <h1 class="brand-name">
{/if} {#if globalProviderFilter.provider}
<span><span style="color: var(--color-primary);">Notify</span> Bridge</span> <span class="brand-mark__icon" style="color: var(--color-primary);"><NavIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={14} /></span>
</h1> {/if}
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p> Notify Bridge
</h1>
<p class="brand-version font-mono">v0.5.2</p>
</div>
</div> </div>
{:else if globalProviderFilter.provider} {:else}
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span> <div class="brand-orb brand-orb--small"></div>
{/if} {/if}
<button onclick={toggleSidebar} <button onclick={toggleSidebar}
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200" class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={collapsed ? t('common.expand') : t('common.collapse')}> title={collapsed ? t('common.expand') : t('common.collapse')}
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} /> aria-label={collapsed ? t('common.expand') : t('common.collapse')}>
<NavIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
</button> </button>
</div> </div>
@@ -393,8 +408,9 @@
providerFilterValue = ids[(idx + 1) % ids.length]; providerFilterValue = ids[(idx + 1) % ids.length];
}} }}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200" 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')}> title={globalProviderFilter.provider?.name || t('common.allProviders')}
<MdiIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} /> aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button> </button>
{:else} {:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact /> <IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
@@ -402,22 +418,12 @@
</div> </div>
{/if} {/if}
<!-- Search button -->
<div class="{collapsed ? 'px-2 py-1.5' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
<button onclick={() => openSearch?.()}
class="search-btn flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
title={t('searchPalette.placeholder')}>
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
<span class="flex-1 text-left text-xs">{t('searchPalette.placeholder')}</span>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">{isMac ? '⌘' : 'Ctrl '}K</kbd>
{/if}
</button>
</div>
<!-- Nav --> <!-- Nav -->
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto"> <nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
{#each navEntries as entry} {#each navEntries as entry}
{#if SECTION_BREAKS[entry.key] && !collapsed}
<div class="nav-section-label">{t(SECTION_BREAKS[entry.key])}</div>
{/if}
{#if isGroup(entry)} {#if isGroup(entry)}
<!-- Group header --> <!-- Group header -->
<button <button
@@ -428,11 +434,11 @@
{#if isGroupActive(entry) && !expandedGroups[entry.key]} {#if isGroupActive(entry) && !expandedGroups[entry.key]}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div> <div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if} {/if}
<MdiIcon name={entry.icon} size={18} /> <NavIcon name={entry.icon} size={18} />
{#if !collapsed} {#if !collapsed}
<span class="truncate flex-1">{t(entry.key)}</span> <span class="truncate flex-1">{t(entry.key)}</span>
<span class="nav-chevron" style="display: inline-flex; transition: transform 0.2s ease; transform: rotate({expandedGroups[entry.key] ? '90deg' : '0deg'});"> <span class="nav-chevron" style="display: inline-flex; transition: transform 0.2s ease; transform: rotate({expandedGroups[entry.key] ? '90deg' : '0deg'});">
<MdiIcon name="mdiChevronRight" size={14} /> <NavIcon name="mdiChevronRight" size={14} />
</span> </span>
{/if} {/if}
</button> </button>
@@ -447,7 +453,7 @@
{#if isActive(child.href)} {#if isActive(child.href)}
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div> <div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if} {/if}
<MdiIcon name={child.icon} size={15} /> <NavIcon name={child.icon} size={15} />
<span class="truncate flex-1">{t(child.key)}</span> <span class="truncate flex-1">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]} {#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm">{navCounts[child.countKey]}</span> <span class="nav-badge-sm">{navCounts[child.countKey]}</span>
@@ -466,7 +472,7 @@
{#if isActive(entry.href)} {#if isActive(entry.href)}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div> <div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if} {/if}
<MdiIcon name={entry.icon} size={18} /> <NavIcon name={entry.icon} size={18} />
{#if !collapsed} {#if !collapsed}
<span class="truncate flex-1">{t(entry.key)}</span> <span class="truncate flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]} {#if entry.countKey && navCounts[entry.countKey]}
@@ -479,121 +485,163 @@
</nav> </nav>
<!-- Footer --> <!-- Footer -->
<div style="border-top: 1px solid var(--color-border);"> <div class="sidebar-foot">
<!-- Theme & Language --> {#if collapsed}
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}"> <div class="flex flex-col items-center gap-1.5 py-3">
<button onclick={toggleLocale} <a href="/docs" target="_blank" rel="noopener noreferrer"
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200" class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={t('common.language')}> title={t('common.apiDocs')}
{getLocale().toUpperCase()} aria-label={t('common.apiDocs')}>
</button> <NavIcon name="mdiApi" size={14} />
<button onclick={cycleTheme} </a>
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
title={t('common.theme')}>
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
</button>
<a href="/docs" target="_blank" rel="noopener noreferrer"
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
title={t('common.apiDocs')}>
<MdiIcon name="mdiApi" size={14} />
</a>
</div>
<!-- User info -->
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={logout} <button onclick={logout}
class="sidebar-icon-btn w-full flex justify-center py-2 rounded-lg transition-all duration-200" class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={t('nav.logout')}> title={t('nav.logout')}
<MdiIcon name="mdiLogout" size={16} /> aria-label={t('nav.logout')}>
<NavIcon name="mdiLogout" size={16} />
</button> </button>
{:else} </div>
<div class="px-1.5"> {:else}
<div class="flex items-center justify-between"> <div class="user-card">
<div class="flex items-center gap-2.5"> <div class="user-card__main">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold" <div class="user-avatar">
style="background: var(--color-primary); color: var(--color-primary-foreground);"> {auth.user.username[0].toUpperCase()}
{auth.user.username[0].toUpperCase()}
</div>
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-[0.65rem] tracking-wide uppercase" style="color: var(--color-muted-foreground);">{auth.user.role}</p>
</div>
</div>
<button onclick={logout}
class="sidebar-icon-btn p-1.5 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
<MdiIcon name="mdiLogout" size={15} />
</button>
</div> </div>
<button onclick={() => showPasswordForm = true} <div class="user-card__text min-w-0">
class="change-pwd-link text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"> <p class="user-card__name truncate">{auth.user.username}</p>
<MdiIcon name="mdiKeyVariant" size={12} /> <p class="user-card__role">{auth.user.role}</p>
{t('common.changePassword')} </div>
<span class="user-card__chip" title={t('dashboard.live')}></span>
</div>
<div class="user-card__actions">
<button onclick={() => showPasswordForm = true} class="user-card__btn"
title={t('common.changePassword')}
aria-label={t('common.changePassword')}>
<NavIcon name="mdiKeyVariant" size={13} />
<span>{t('common.changePassword')}</span>
</button>
<a href="/docs" target="_blank" rel="noopener noreferrer"
class="user-card__btn" title={t('common.apiDocs')}
aria-label={t('common.apiDocs')}>
<NavIcon name="mdiApi" size={13} />
</a>
<button onclick={logout} class="user-card__btn user-card__btn--danger"
title={t('nav.logout')}
aria-label={t('nav.logout')}>
<NavIcon name="mdiLogout" size={13} />
</button> </button>
</div> </div>
{/if} </div>
</div> {/if}
</div> </div>
</aside> </aside>
<!-- Mobile bottom nav --> <!-- Mobile bottom nav -->
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);"> <nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
{#each mobileNavItems as item} {#each mobileNavItems as item}
<a href={item.href} aria-label={t(item.key)} <a href={item.href} aria-label={t(item.key)}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200" class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};"> style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<MdiIcon name={item.icon} size={20} /> <NavIcon name={item.icon} size={20} />
</a> </a>
{/each} {/each}
<button onclick={() => openSearch?.()} aria-label={t('searchPalette.placeholder')} <button onclick={() => openSearch?.()} aria-label={t('searchPalette.placeholder')}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200" class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);"> style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiMagnify" size={20} /> <NavIcon name="mdiMagnify" size={20} />
</button> </button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')} <button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200" 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)'};"> style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<MdiIcon name="mdiDotsHorizontal" size={20} /> <NavIcon name="mdiDotsHorizontal" size={20} />
</button> </button>
</nav> </nav>
<!-- Mobile "More" panel --> <!-- Mobile "More" panel — mirrors the full desktop nav tree -->
{#if mobileMoreOpen} {#if mobileMoreOpen}
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);" <div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
onclick={() => mobileMoreOpen = false} role="presentation"></div> onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;" <div class="mobile-more-panel"
transition:slide={{ duration: 200, easing: cubicOut }}> transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1} {#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);"> <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} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div> </div>
{/if} {/if}
<div class="grid grid-cols-3 gap-2"> <div class="space-y-3">
{#each mobileMoreItems as item} {#each navEntries as entry}
<a href={item.href} {#if isGroup(entry)}
onclick={() => mobileMoreOpen = false} <div>
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200" <div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};" style="color: var(--color-muted-foreground);">
> <NavIcon name={entry.icon} size={13} />
<MdiIcon name={item.icon} size={20} /> <span>{t(entry.key)}</span>
<span class="text-xs text-center leading-tight">{t(item.key)}</span> </div>
</a> <div class="grid grid-cols-3 gap-2">
{#each entry.children as child}
<a href={child.href} onclick={closeMobileMore}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<NavIcon name={child.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
{/if}
</a>
{/each}
</div>
</div>
{:else}
<a href={entry.href} onclick={closeMobileMore}
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<NavIcon name={entry.icon} size={18} />
<span class="text-sm flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]}
<span class="nav-badge">{navCounts[entry.countKey]}</span>
{/if}
</a>
{/if}
{/each} {/each}
<button onclick={() => { mobileMoreOpen = false; logout(); }} <div class="pt-2" style="border-top: 1px solid var(--color-border);">
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200" <button onclick={() => { closeMobileMore(); logout(); }}
style="color: var(--color-muted-foreground);"> class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
<MdiIcon name="mdiLogout" size={20} /> style="color: var(--color-muted-foreground);">
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span> <NavIcon name="mdiLogout" size={18} />
</button> <span class="text-sm">{t('nav.logout')}</span>
</button>
</div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Main content --> <!-- Main content -->
<main class="flex-1 overflow-auto pb-16 md:pb-0"> <main class="main-col flex-1 overflow-auto md:pb-0"
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
<!-- Always-visible topbar — search + utilities + primary CTA -->
<div class="topbar">
<div class="topbar-glass">
<button type="button" class="topbar-search" onclick={() => openSearch?.()}>
<NavIcon name="mdiMagnify" size={16} />
<span class="topbar-search__text">{t('searchPalette.placeholder')}</span>
<span class="topbar-search__kbd font-mono">{isMac ? '⌘' : 'Ctrl '}K</span>
</button>
<button type="button" class="topbar-icon-btn" onclick={cycleTheme}
title={t('common.theme')} aria-label={t('common.theme')}>
<NavIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={16} />
</button>
<button type="button" class="topbar-icon-btn" onclick={toggleLocale}
title={t('common.language')} aria-label={t('common.language')}>
<span class="topbar-locale font-mono">{getLocale().toUpperCase()}</span>
</button>
</div>
</div>
{#key page.url.pathname} {#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}> <div class="pb-4 md:pb-8" style="padding-top: 12px;" in:fade={{ duration: 200, delay: 50 }}>
{@render children()} {@render children()}
</div> </div>
{/key} {/key}
@@ -611,19 +659,22 @@
<!-- Password change modal --> <!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}> <Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3"> <form onsubmit={changePassword} class="space-y-3">
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
readonly aria-hidden="true" tabindex="-1"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
<div> <div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label> <label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required <input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label> <label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8" <input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label> <label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8" <input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
{#if pwdMsg} {#if pwdMsg}
@@ -640,44 +691,103 @@
<SearchPalette onopen={(fn) => openSearch = fn} /> <SearchPalette onopen={(fn) => openSearch = fn} />
<style> <style>
@media (max-width: 767px) { /* === AURORA SHELL === */
.mobile-nav { display: flex !important; } .app-shell {
.mobile-more-panel a:hover, display: flex;
.mobile-more-panel button:hover { min-height: 100vh;
background: var(--color-muted); padding: 18px;
} gap: 18px;
} }
/* Provider filter chips */ /* === SIDEBAR — frosted glass rail === */
.provider-chip { .sidebar {
display: inline-flex; background: var(--color-glass);
align-items: center; backdrop-filter: blur(28px) saturate(160%);
gap: 0.25rem; -webkit-backdrop-filter: blur(28px) saturate(160%);
padding: 0.2rem 0.4rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: transparent; border-radius: 22px;
color: var(--color-muted-foreground); box-shadow: var(--shadow-card);
cursor: pointer; position: sticky;
transition: all 0.15s; top: 18px;
height: calc(100vh - 36px);
overflow: hidden;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
z-index: 0;
}
.sidebar > * { position: relative; z-index: 1; }
.sidebar-header {
border-bottom: 1px solid var(--color-border);
}
/* Brand — snapped to Aurora mockup: bold sans wordmark + mono version */
.brand-text { line-height: 1.1; min-width: 0; }
.brand-name {
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
margin: 0;
display: flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap; white-space: nowrap;
} }
.provider-chip:hover { .brand-mark__icon {
border-color: var(--color-primary); display: inline-flex;
color: var(--color-primary); align-items: center;
} }
.provider-chip.active { .brand-version {
border-color: var(--color-primary); font-size: 0.65rem;
background: color-mix(in srgb, var(--color-primary) 10%, transparent); color: var(--color-muted-foreground);
color: var(--color-primary); margin: 3px 0 0;
letter-spacing: 0.02em;
font-weight: 500;
} }
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
background: conic-gradient(from 220deg, var(--color-primary), var(--color-orchid), var(--color-mint), var(--color-primary));
box-shadow: 0 4px 14px var(--color-glow);
position: relative;
flex-shrink: 0;
}
.brand-orb::after {
content: '';
position: absolute; inset: 4px;
border-radius: 7px;
background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.6), transparent 50%);
}
.brand-orb--small { width: 26px; height: 26px; border-radius: 9px; }
/* User avatar */
.user-avatar {
width: 30px; height: 30px;
border-radius: 50%;
display: grid; place-items: center;
background: linear-gradient(135deg, var(--color-orchid), var(--color-primary));
color: white;
font-weight: 600;
font-size: 0.78rem;
box-shadow: 0 0 0 2px var(--color-glass) inset;
}
.provider-filter-btn { .provider-filter-btn {
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
background: transparent; background: transparent;
} }
.provider-filter-btn:hover { .provider-filter-btn:hover {
color: var(--color-primary); color: var(--color-foreground);
background: var(--color-muted); background: var(--color-glass-strong);
} }
/* Sidebar icon button (toggle, logout) */ /* Sidebar icon button (toggle, logout) */
@@ -686,88 +796,289 @@
background: transparent; background: transparent;
} }
.sidebar-icon-btn:hover { .sidebar-icon-btn:hover {
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-foreground); color: var(--color-foreground);
} }
/* Search button */ /* Nav links — soft glass hovers, gradient bar on active.
.search-btn { Snapped from the Aurora dashboard mockup. */
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.search-btn:hover {
border-color: var(--color-primary);
color: var(--color-foreground);
}
/* Nav links (top-level items, group headers, group children) */
.nav-link { .nav-link {
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
background: transparent; background: transparent;
font-weight: 400; font-weight: 450;
border-radius: 12px !important;
font-size: 13.5px;
letter-spacing: -0.005em;
} }
.nav-link:not(.active):hover { .nav-link:not(.active):hover {
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-foreground); color: var(--color-foreground);
} }
.nav-link.active { .nav-link.active {
color: var(--color-primary); color: var(--color-foreground);
font-weight: 500; font-weight: 500;
background: var(--color-glass-elev);
box-shadow: inset 0 1px 0 var(--color-highlight), 0 4px 18px -8px var(--color-glow);
} }
.nav-link.active-bg { .nav-link.active-bg {
background: var(--color-sidebar-active); background: var(--color-glass-elev);
} }
/* Footer pill buttons (locale, theme) */ /* Sidebar footer card */
.footer-pill { .sidebar-foot {
background: var(--color-muted); padding: 0.85rem 0.85rem 1rem;
color: var(--color-muted-foreground);
} }
.footer-pill:hover { .user-card {
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 0.75rem 0.85rem 0.6rem;
box-shadow: inset 0 1px 0 var(--color-highlight);
}
.user-card__main {
display: flex; align-items: center; gap: 0.7rem;
}
.user-card__text { line-height: 1.15; }
.user-card__name {
font-size: 0.82rem;
font-weight: 500;
color: var(--color-foreground); color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow); margin: 0;
}
.user-card__role {
font-size: 0.6rem;
color: var(--color-muted-foreground);
margin: 2px 0 0;
text-transform: uppercase;
letter-spacing: 0.13em;
}
.user-card__chip {
margin-left: auto;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--color-mint);
box-shadow: 0 0 8px var(--color-mint);
flex-shrink: 0;
}
.user-card__actions {
display: flex; gap: 0.3rem;
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid var(--color-border);
}
.user-card__btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
padding: 0.35rem 0.55rem;
flex: 1;
font-size: 0.65rem;
color: var(--color-muted-foreground);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
text-decoration: none;
}
.user-card__btn span {
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-card__btn:not(.user-card__btn--danger):not(:has(span)) { flex: 0 0 auto; }
.user-card__btn:hover {
background: var(--color-glass-elev);
color: var(--color-foreground);
}
.user-card__btn--danger:hover {
background: var(--color-error-bg);
color: var(--color-error-fg);
} }
/* Change password link */ /* Section labels above each nav group */
.change-pwd-link { .nav-section-label {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
padding: 0.85rem 0.85rem 0.4rem;
font-family: var(--font-mono);
} }
.change-pwd-link:hover { .nav-section-label::after {
color: var(--color-primary); content: '';
flex: 1;
height: 1px;
background: var(--color-border);
} }
/* Primary action button (password form submit) */ /* Primary action button (password form submit) */
.primary-btn { .primary-btn {
background: var(--color-primary); background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: var(--color-primary-foreground); color: white;
border: 0;
box-shadow: 0 6px 20px -8px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.3);
} }
.primary-btn:hover { .primary-btn:hover {
box-shadow: 0 0 16px var(--color-glow-strong); transform: translateY(-1px);
box-shadow: 0 8px 24px -6px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.3);
} }
.nav-badge { .nav-badge {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 600; font-weight: 500;
padding: 0.1rem 0.4rem; padding: 0.12rem 0.45rem;
border-radius: 9999px; border-radius: 9999px;
background: var(--color-primary); background: var(--color-glass-elev);
color: var(--color-primary-foreground); color: var(--color-foreground);
font-family: var(--font-mono); font-family: var(--font-mono);
line-height: 1.2; line-height: 1.2;
min-width: 1.2rem; min-width: 1.2rem;
text-align: center; text-align: center;
border: 1px solid var(--color-border);
}
.nav-link.active .nav-badge {
background: var(--color-primary);
color: var(--color-primary-foreground);
border-color: transparent;
} }
.nav-badge-sm { .nav-badge-sm {
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 600; font-weight: 500;
padding: 0.05rem 0.35rem; padding: 0.06rem 0.4rem;
border-radius: 9999px; border-radius: 9999px;
background: var(--color-muted); background: var(--color-glass-strong);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
font-family: var(--font-mono); font-family: var(--font-mono);
line-height: 1.2; line-height: 1.2;
min-width: 1rem; min-width: 1rem;
text-align: center; text-align: center;
} }
/* === TOPBAR — always-visible search + utility row === */
.main-col {
display: flex;
flex-direction: column;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
flex-shrink: 0;
}
.topbar-glass {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem 0.5rem 0.85rem;
background: var(--color-glass);
backdrop-filter: blur(14px) saturate(150%);
-webkit-backdrop-filter: blur(14px) saturate(150%);
border: 1px solid var(--color-border);
border-radius: 18px;
box-shadow: var(--shadow-card);
position: relative;
}
.topbar-glass::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.topbar-search {
flex: 1;
display: inline-flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.85rem;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-muted-foreground);
font-size: 0.85rem;
font-family: inherit;
cursor: text;
transition: all 0.15s;
text-align: left;
}
.topbar-search:hover {
background: var(--color-glass-elev);
border-color: var(--color-rule-strong);
}
.topbar-search__text {
flex: 1;
text-align: left;
}
.topbar-search__kbd {
font-size: 0.65rem;
padding: 2px 7px;
border-radius: 6px;
background: var(--color-glass);
border: 1px solid var(--color-border);
color: var(--color-foreground);
}
.topbar-icon-btn {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s;
}
.topbar-icon-btn:hover {
background: var(--color-glass-elev);
color: var(--color-foreground);
}
.topbar-locale {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
}
@media (max-width: 720px) {
.topbar-search__kbd { display: none; }
}
/* Mobile bottom-nav */
@media (max-width: 767px) {
.app-shell {
padding: 0;
gap: 0;
}
.sidebar {
border-radius: 0;
border: 0;
border-right: 1px solid var(--color-border);
}
.mobile-nav { display: flex !important; }
.mobile-more-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
z-index: 50;
background: var(--mobile-more-bg, rgba(19, 21, 32, 0.92));
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border-top: 1px solid var(--color-rule-strong);
padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 1rem calc(1rem + env(safe-area-inset-left, 0px));
overflow-y: auto;
overscroll-behavior: contain;
}
:global([data-theme="light"]) .mobile-more-panel { --mobile-more-bg: rgba(250, 250, 254, 0.92); }
.mobile-more-panel a:hover,
.mobile-more-panel button:hover {
background: var(--color-glass-strong);
}
.topbar { display: none; }
}
</style> </style>
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -68,6 +68,16 @@
})()); })());
onMount(load); onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'citrus' }> = [];
const enabled = actions.filter((a: Action) => a.enabled).length;
const disabled = actions.length - enabled;
if (enabled > 0) pills.push({ label: `${enabled} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (disabled > 0) pills.push({ label: `${disabled} ${t('notificationTracker.paused')}`, tone: 'citrus' });
return pills;
});
async function load() { async function load() {
try { try {
await Promise.all([ await Promise.all([
@@ -171,7 +181,15 @@
} }
</script> </script>
<PageHeader title={t('actions.title')} description={t('actions.description')}> <PageHeader
title={t('actions.title')}
emphasis={t('actions.titleEmphasis')}
description={t('actions.description')}
crumb="Routing · Automation"
count={actions.length}
countLabel={t('actions.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('actions.addAction')} {showForm ? t('common.cancel') : t('actions.addAction')}
</Button> </Button>
@@ -196,14 +214,14 @@
{#if error}<ErrorBanner message={error} />{/if} {#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={save} class="space-y-3"> <form onsubmit={save} class="space-y-3">
<div> <div>
<label class="block text-sm font-medium mb-1">{t('actions.provider')}</label> <div class="block text-sm font-medium mb-1">{t('actions.provider')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id} <EntitySelect items={providerItems} bind:value={form.provider_id}
placeholder={t('actions.selectProvider')} disabled={!!editing} /> placeholder={t('actions.selectProvider')} disabled={!!editing} />
</div> </div>
{#if actionTypes.length > 0} {#if actionTypes.length > 0}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('actions.actionType')}</label> <div class="block text-sm font-medium mb-1">{t('actions.actionType')}</div>
{#if !editing} {#if !editing}
<div class="space-y-1"> <div class="space-y-1">
{#each actionTypes as at} {#each actionTypes as at}
@@ -233,7 +251,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('actions.schedule')}</label> <div class="block text-sm font-medium mb-1">{t('actions.schedule')}</div>
<div class="flex gap-2 items-center mb-2"> <div class="flex gap-2 items-center mb-2">
<label class="flex items-center gap-1 text-sm"> <label class="flex items-center gap-1 text-sm">
<input type="radio" name="schedule_type" value="interval" bind:group={form.schedule_type} class="accent-[var(--color-primary)]" /> <input type="radio" name="schedule_type" value="interval" bind:group={form.schedule_type} class="accent-[var(--color-primary)]" />
+13 -13
View File
@@ -153,8 +153,8 @@
{#if showAddForm} {#if showAddForm}
<div class="border border-[var(--color-border)] rounded-md p-3 space-y-2 bg-[var(--color-muted)]/30"> <div class="border border-[var(--color-border)] rounded-md p-3 space-y-2 bg-[var(--color-muted)]/30">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label> <label for="rule-name-new" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input bind:value={newRule.name} placeholder={t('actions.ruleNamePlaceholder')} <input id="rule-name-new" bind:value={newRule.name} placeholder={t('actions.ruleNamePlaceholder')}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
@@ -189,8 +189,8 @@
{#if expandedRule === rule.id} {#if expandedRule === rule.id}
<div class="mt-2 pt-2 border-t border-[var(--color-border)] space-y-2"> <div class="mt-2 pt-2 border-t border-[var(--color-border)] space-y-2">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label> <label for="rule-name-{rule.id}" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input bind:value={rule.name} <input id="rule-name-{rule.id}" bind:value={rule.name}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
@@ -219,7 +219,7 @@
<!-- Person selector --> <!-- Person selector -->
{#if personItems.length > 0} {#if personItems.length > 0}
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.persons')}</label> <div class="block text-xs font-medium mb-1">{t('actions.persons')}</div>
<MultiEntitySelect items={personItems} <MultiEntitySelect items={personItems}
bind:values={ruleConfig.criteria.person_ids} bind:values={ruleConfig.criteria.person_ids}
placeholder={t('actions.addPerson')} placeholder={t('actions.addPerson')}
@@ -231,7 +231,7 @@
<!-- Person excludes --> <!-- Person excludes -->
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.excludePersons')}</label> <div class="block text-xs font-medium mb-1">{t('actions.excludePersons')}</div>
<MultiEntitySelect items={personItems} <MultiEntitySelect items={personItems}
bind:values={ruleConfig.criteria.exclude_person_ids} bind:values={ruleConfig.criteria.exclude_person_ids}
placeholder={t('actions.addExcludePerson')} placeholder={t('actions.addExcludePerson')}
@@ -244,14 +244,14 @@
<!-- Smart search query --> <!-- Smart search query -->
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.searchQuery')}</label> <div class="block text-xs font-medium mb-1">{t('actions.searchQuery')}</div>
<input bind:value={ruleConfig.criteria.query} placeholder={t('actions.searchQueryPlaceholder')} <input bind:value={ruleConfig.criteria.query} placeholder={t('actions.searchQueryPlaceholder')}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
<!-- Asset type --> <!-- Asset type -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="text-xs font-medium">{t('actions.assetType')}:</label> <span class="text-xs font-medium">{t('actions.assetType')}:</span>
{#each ['all', 'image', 'video'] as at} {#each ['all', 'image', 'video'] as at}
<label class="flex items-center gap-1 text-xs"> <label class="flex items-center gap-1 text-xs">
<input type="radio" <input type="radio"
@@ -266,12 +266,12 @@
<!-- Date range --> <!-- Date range -->
<div class="flex gap-2"> <div class="flex gap-2">
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-medium mb-1">{t('actions.dateFrom')}</label> <div class="block text-xs font-medium mb-1">{t('actions.dateFrom')}</div>
<input type="date" bind:value={ruleConfig.criteria.date_from} <input type="date" bind:value={ruleConfig.criteria.date_from}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-medium mb-1">{t('actions.dateTo')}</label> <div class="block text-xs font-medium mb-1">{t('actions.dateTo')}</div>
<input type="date" bind:value={ruleConfig.criteria.date_to} <input type="date" bind:value={ruleConfig.criteria.date_to}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
@@ -290,7 +290,7 @@
{#if albumItems.length > 0} {#if albumItems.length > 0}
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.selectAlbum')}</label> <div class="block text-xs font-medium mb-1">{t('actions.selectAlbum')}</div>
<MultiEntitySelect items={albumItems} <MultiEntitySelect items={albumItems}
bind:values={ruleConfig.target_album_ids} bind:values={ruleConfig.target_album_ids}
placeholder={t('actions.selectAlbumPlaceholder')} placeholder={t('actions.selectAlbumPlaceholder')}
@@ -301,7 +301,7 @@
</div> </div>
{:else} {:else}
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.albumId')}</label> <div class="block text-xs font-medium mb-1">{t('actions.albumId')}</div>
<input bind:value={ruleConfig.target_album_id} <input bind:value={ruleConfig.target_album_id}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
</div> </div>
@@ -314,7 +314,7 @@
{#if ruleConfig.create_album_if_missing} {#if ruleConfig.create_album_if_missing}
<div> <div>
<label class="block text-xs font-medium mb-1">{t('actions.newAlbumName')}</label> <div class="block text-xs font-medium mb-1">{t('actions.newAlbumName')}</div>
<input bind:value={ruleConfig.create_album_name} <input bind:value={ruleConfig.create_album_name}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div> </div>
+8 -1
View File
@@ -86,7 +86,14 @@
} }
</script> </script>
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}> <PageHeader
title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')}
crumb="Operators · Bots"
count={emailBots.length}
countLabel={t('emailBot.countLabel')}
>
<Button size="sm" onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}> <Button size="sm" onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}>
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')} {showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</Button> </Button>
+8 -1
View File
@@ -84,7 +84,14 @@
} }
</script> </script>
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}> <PageHeader
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
<Button size="sm" onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}> <Button size="sm" onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}>
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')} {showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
</Button> </Button>
+28 -4
View File
@@ -285,7 +285,14 @@
} }
</script> </script>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}> <PageHeader
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('telegramBot.addBot')} {showForm ? t('common.cancel') : t('telegramBot.addBot')}
</Button> </Button>
@@ -334,10 +341,12 @@
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span> <span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
{/if} {/if}
<!-- Mode badge --> <!-- Mode badge -->
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook' <span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}"> : (bot.update_mode || 'none') === 'polling'
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')} ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span> </span>
</div> </div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p> <p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
@@ -456,6 +465,14 @@
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p> <p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden"> <div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<button onclick={() => switchMode(bot.id, 'none')}
disabled={modeChanging[bot.id] || (bot.update_mode || 'none') === 'none'}
class="px-3 py-1 text-xs transition-colors {(bot.update_mode || 'none') === 'none'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.none')}
</button>
<button onclick={() => switchMode(bot.id, 'polling')} <button onclick={() => switchMode(bot.id, 'polling')}
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'} disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling' class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
@@ -474,6 +491,13 @@
</button> </button>
</div> </div>
{#if (bot.update_mode || 'none') === 'none'}
<span class="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.noneActive')}
</span>
{/if}
{#if bot.update_mode === 'polling'} {#if bot.update_mode === 'polling'}
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1"> <span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
<MdiIcon name="mdiCheckCircle" size={14} /> <MdiIcon name="mdiCheckCircle" size={14} />
@@ -80,6 +80,14 @@
let hasCommands = $derived(providerCommands.length > 0); let hasCommands = $derived(providerCommands.length > 0);
onMount(load); onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() { async function load() {
try { try {
await Promise.all([ await Promise.all([
@@ -161,7 +169,15 @@
} }
</script> </script>
<PageHeader title={t('commandConfig.title')} description={t('commandConfig.description')}> <PageHeader
title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')}
crumb="Routing · Commands"
count={configs.length}
countLabel={t('commandConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('commandConfig.newConfig')} {showForm ? t('common.cancel') : t('commandConfig.newConfig')}
</Button> </Button>
@@ -183,7 +199,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label> <div class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</div>
{#if !editing} {#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} /> <IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
{:else} {:else}
@@ -208,30 +224,30 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</label> <div class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</div>
<EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} /> <EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} />
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div> <div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label> <div class="block text-xs mb-1">{t('commandConfig.responseMode')}</div>
<IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} compact /> <IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} compact />
</div> </div>
<div> <div>
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label> <label for="cfg-default-count" class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
<input type="number" bind:value={form.default_count} min="1" max="20" <input id="cfg-default-count" type="number" bind:value={form.default_count} min="1" max="20"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label> <label for="cfg-search-cooldown" class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
<input type="number" bind:value={form.rate_limits.search} min="0" max="300" <input id="cfg-search-cooldown" type="number" bind:value={form.rate_limits.search} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div> </div>
</div> </div>
<div class="w-1/2 sm:w-1/4"> <div class="w-1/2 sm:w-1/4">
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label> <label for="cfg-default-cooldown" class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
<input type="number" bind:value={form.rate_limits.default} min="0" max="300" <input id="cfg-default-cooldown" type="number" bind:value={form.rate_limits.default} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" /> class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div> </div>
{:else} {:else}
@@ -7,6 +7,7 @@
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
@@ -19,6 +20,8 @@
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte'; import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -40,6 +43,7 @@
} }
let LOCALES = $derived(supportedLocalesCache.items); let LOCALES = $derived(supportedLocalesCache.items);
let primaryLocale = $derived(LOCALES[0] || 'en');
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]); let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state(''); let filterText = $state('');
@@ -54,6 +58,11 @@
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let error = $state(''); let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null); let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({}); let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({}); let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({}); let slotErrorLines = $state<Record<string, number | null>>({});
@@ -67,7 +76,18 @@
}); });
let varsRef = $state<Record<string, any>>({}); let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null); let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en'); let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
let expandedSlots = $state<Set<string>>(new Set()); let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state(''); let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set()); let showPreviewFor = $state<Set<string>>(new Set());
@@ -117,6 +137,14 @@
return form.slots[slotName]?.[activeLocale] || ''; return form.slots[slotName]?.[activeLocale] || '';
} }
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
function getVarsFor(slotName: string) {
const providerVars = varsRef[form.provider_type];
return providerVars?.[slotName] ?? varsRef[slotName];
}
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
/** Set slot template for current locale (immutable update). */ /** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) { function setSlotValue(slotName: string, value: string) {
form.slots = { form.slots = {
@@ -127,6 +155,13 @@
onMount(load); onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() { async function load() {
try { try {
const [cfgs, caps, vars] = await Promise.all([ const [cfgs, caps, vars] = await Promise.all([
@@ -194,7 +229,7 @@
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0]; if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -217,7 +252,7 @@
}; };
editing = c.id; editing = c.id;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -245,6 +280,58 @@
} }
} }
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: CmdTemplateConfig) { function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {}; const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) { for (const [k, v] of Object.entries(c.slots)) {
@@ -259,7 +346,7 @@
}; };
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -290,11 +377,18 @@
} }
</script> </script>
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.description')}> <PageHeader
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }} title={t('cmdTemplateConfig.title')}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90"> emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')} {showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')}
</button> </Button>
</PageHeader> </PageHeader>
{#if !loaded}<Loading />{:else} {#if !loaded}<Loading />{:else}
@@ -320,7 +414,7 @@
{#if !editing} {#if !editing}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label> <div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
<IconGridSelect items={providerTypeItemsFn()} bind:value={form.provider_type} columns={2} /> <IconGridSelect items={providerTypeItemsFn()} bind:value={form.provider_type} columns={2} />
</div> </div>
{:else} {:else}
@@ -334,15 +428,27 @@
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend> <legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p> <p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs --> <!-- Language picker -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]"> <div class="flex items-center gap-2 mb-3">
{#each LOCALES as loc} <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
<button type="button" {t('templateConfig.language')}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}" </span>
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}> <div class="flex-1 max-w-xs">
{loc.toUpperCase()} <EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button> </button>
{/each} {/if}
</div> </div>
<!-- Slot filter --> <!-- Slot filter -->
@@ -369,10 +475,15 @@
{t('templateConfig.preview')} {t('templateConfig.preview')}
</button> </button>
{/if} {/if}
{#if varsRef[slot.name]} {#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name} <button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button> class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if} {/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div> </div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]} {#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
@@ -385,13 +496,13 @@
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }} onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3} rows={3}
errorLine={slotErrorLines[slot.name] || null} errorLine={slotErrorLines[slot.name] || null}
variables={varsRef[slot.name] || undefined} variables={getVarsFor(slot.name) || undefined}
/> />
{/if} {/if}
{#if slotErrors[slot.name]} {#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'} {#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{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} {: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}
@@ -464,15 +575,23 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} /> <BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}> <Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]} {#if showVarsFor && modalVars}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p> <p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p> <p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]} {#each Object.entries(modalVars.variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm"> <div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code> <code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span> <span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
@@ -484,11 +603,19 @@
['album_fields', 'album', 'Album fields'], ['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'], ['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'], ['event_fields', 'event', 'Event fields'],
['repo_fields', 'repo', 'Repository fields'],
['issue_fields', 'issue', 'Issue fields'],
['pr_fields', 'pr', 'Pull request fields'],
['commit_fields', 'c', 'Commit fields'],
['board_fields', 'board', 'Board fields'],
['card_fields', 'card', 'Card fields'],
['list_fields', 'lst', 'List fields'],
['device_fields', 'd', 'Device fields'],
] as [fieldKey, prefix, title]} ] as [fieldKey, prefix, title]}
{#if varsRef[showVarsFor][fieldKey]} {#if modalVars[fieldKey]}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]"> <div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p> <p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]} {#each Object.entries(modalVars[fieldKey]) as [name, desc]}
<div class="flex items-start gap-2 text-sm"> <div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code> <code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span> <span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte'; import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,7 +73,24 @@
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType) .filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type }))); .map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
onMount(load); onMount(() => {
topbarAction.set({
label: t('commandTracker.newTracker'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'citrus' }> = [];
const armed = trackers.filter((tr: { enabled?: boolean }) => tr.enabled).length;
const paused = trackers.length - armed;
if (armed > 0) pills.push({ label: `${armed} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (paused > 0) pills.push({ label: `${paused} ${t('notificationTracker.paused')}`, tone: 'citrus' });
return pills;
});
async function load() { async function load() {
try { try {
[allCmdTrackers] = await Promise.all([ [allCmdTrackers] = await Promise.all([
@@ -226,7 +244,15 @@
} }
</script> </script>
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}> <PageHeader
title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')}
crumb="Routing · Commands"
count={trackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('commandTracker.newTracker')} {showForm ? t('common.cancel') : t('commandTracker.newTracker')}
</Button> </Button>
@@ -248,12 +274,12 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label> <div class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('commandTracker.selectProvider')} /> <EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('commandTracker.selectProvider')} />
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</label> <div class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</div>
<EntitySelect items={configItems} bind:value={form.command_config_id} placeholder={t('commandTracker.selectCommandConfig')} /> <EntitySelect items={configItems} bind:value={form.command_config_id} placeholder={t('commandTracker.selectCommandConfig')} />
</div> </div>
+26 -2
View File
@@ -15,13 +15,32 @@
let submitting = $state(false); let submitting = $state(false);
let mounted = $state(false); let mounted = $state(false);
let backendDown = $state(false);
onMount(async () => { onMount(async () => {
initTheme(); initTheme();
mounted = true; mounted = true;
// If the user is already signed in (valid access token in storage),
// there is no reason to show them the login form. loadUser() runs in
// the root layout; we just check the resolved state after a short tick.
const { isAuthenticated } = await import('$lib/api');
if (isAuthenticated()) {
try {
await api('/auth/me');
goto('/');
return;
} catch {
// Token was stale; fall through to the login form.
}
}
try { try {
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup'); const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup'); if (res.needs_setup) goto('/setup');
} catch { /* ignore */ } } catch {
// The backend is unreachable — surface that distinctly so the user
// doesn't blame the login form for a network/backend problem.
backendDown = true;
}
}); });
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
@@ -62,7 +81,12 @@
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p> <p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
</div> </div>
{#if error} {#if backendDown}
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{t('auth.backendUnreachable')}
</div>
{:else if error}
<div class="auth-error animate-fade-slide-in"> <div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} /> <MdiIcon name="mdiAlertCircle" size={16} />
{error} {error}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -62,7 +63,8 @@
// Tracker form // Tracker form
const defaultForm = () => ({ const defaultForm = () => ({
name: '', icon: '', provider_id: 0, collection_ids: [] as string[], name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
scan_interval: 60, batch_duration: 0, scan_interval: 60,
adaptive_max_skip: null as number | null,
default_tracking_config_id: 0, default_template_config_id: 0, default_tracking_config_id: 0, default_template_config_id: 0,
filters: {} as Record<string, any>, filters: {} as Record<string, any>,
}); });
@@ -84,17 +86,23 @@
let testMenuStyle = $state(''); let testMenuStyle = $state('');
// Test types: basic is always available; periodic/scheduled/memory only for providers // Test types: basic is always available; periodic/scheduled/memory only for providers
// that have those notification slots in their capabilities // that have those notification slots in their capabilities AND have the feature
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = { // 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
// the test button would just surface a silent skip.
const allTestTypes: Record<string, {
key: string; icon: string; labelKey: string;
requiredSlot?: string; enabledField?: string;
}> = {
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' }, basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' }, periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' }, scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' }, memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
}; };
let testMenuTrackerId = $state<number | null>(null); let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived.by(() => { let testTypes = $derived.by(() => {
const base = [allTestTypes.basic]; const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
if (!testMenuTrackerId) return base; if (!testMenuTrackerId) return base;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId); const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return base; if (!tracker) return base;
@@ -103,13 +111,41 @@
const caps = allCapabilities[provider.type]; const caps = allCapabilities[provider.type];
if (!caps) return base; if (!caps) return base;
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name)); const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) { for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt); if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
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
// 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',
});
} }
return base; return base;
}); });
onMount(load); onMount(() => {
topbarAction.set({
label: t('notificationTracker.newTracker'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
const armed = notificationTrackers.filter(t => t.enabled).length;
const paused = notificationTrackers.length - armed;
if (armed > 0) pills.push({ label: `${armed} ${t('notificationTracker.armed')}`, tone: 'mint' });
if (paused > 0) pills.push({ label: `${paused} ${t('notificationTracker.paused')}`, tone: 'citrus' });
const providerCount = new Set(notificationTrackers.map(t => t.provider_id)).size;
if (providerCount > 0) pills.push({ label: `${providerCount} ${providerCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() { async function load() {
loadError = ''; loadError = '';
@@ -164,7 +200,8 @@
form = { form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id, name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])], collection_ids: [...(trk.collection_ids || [])],
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0, scan_interval: trk.scan_interval,
adaptive_max_skip: trk.adaptive_max_skip ?? null,
default_tracking_config_id: trk.default_tracking_config_id ?? 0, default_tracking_config_id: trk.default_tracking_config_id ?? 0,
default_template_config_id: trk.default_template_config_id ?? 0, default_template_config_id: trk.default_template_config_id ?? 0,
filters: trk.filters || {}, filters: trk.filters || {},
@@ -207,6 +244,12 @@
...form, ...form,
default_tracking_config_id: form.default_tracking_config_id || null, default_tracking_config_id: form.default_tracking_config_id || null,
default_template_config_id: form.default_template_config_id || null, default_template_config_id: form.default_template_config_id || null,
// Empty string, 0, or null all mean "disable adaptive polling".
// Coerce to null so the DB column stays NULL rather than 0.
adaptive_max_skip:
form.adaptive_max_skip && form.adaptive_max_skip > 1
? form.adaptive_max_skip
: null,
}; };
if (editing) { if (editing) {
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) }); await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
@@ -392,7 +435,15 @@
} }
</script> </script>
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}> <PageHeader
title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')}
crumb="Routing · Notification"
count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')} {showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
</Button> </Button>
@@ -516,6 +567,15 @@
onclose={() => { linkWarning = null; }} onclose={() => { linkWarning = null; }}
onautoCreate={autoCreateLinks} onautoCreate={autoCreateLinks}
ondismiss={dismissLinkWarning} ondismiss={dismissLinkWarning}
onupdate={(remaining) => {
if (!linkWarning) return;
if (remaining.length === 0) {
linkWarning = null;
doSave();
} else {
linkWarning = { ...linkWarning, albums: remaining };
}
}}
/> />
<ConfirmModal <ConfirmModal
@@ -129,13 +129,13 @@
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}> <div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div> <div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</label> <div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</div>
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id} <EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')} placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} /> onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div> </div>
<div> <div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</label> <div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</div>
<EntitySelect items={templateConfigItems} value={tt.template_config_id} <EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')} placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} /> onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
@@ -1,17 +1,50 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
interface AlbumIssue { id: string; name: string; issue: string }
interface Props { interface Props {
linkWarning: { albums: any[]; providerId: number } | null; linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
linkCreating: boolean; linkCreating: boolean;
onclose: () => void; onclose: () => void;
onautoCreate: () => void; onautoCreate: () => void;
ondismiss: () => void; ondismiss: () => void;
/** Called with the updated warning list after a per-row replace. */
onupdate?: (albums: AlbumIssue[]) => void;
} }
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props(); let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
/** Per-row loading state for the "Replace" button. */
let replacing = $state<Record<string, boolean>>({});
/**
* 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.
*/
async function replaceOne(album: AlbumIssue) {
if (!linkWarning) return;
replacing = { ...replacing, [album.id]: true };
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
method: 'POST',
body: JSON.stringify({ replace: true }),
});
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);
} finally {
replacing = { ...replacing, [album.id]: false };
}
}
</script> </script>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}> <Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
@@ -19,13 +52,26 @@
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);"> <p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('notificationTracker.missingLinksDesc')} {t('notificationTracker.missingLinksDesc')}
</p> </p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto"> <div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
{#each linkWarning.albums as album} {#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30"> <div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span> <div class="flex-1 min-w-0">
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}"> <span class="font-medium truncate block">{album.name}</span>
{#if album.issue === 'password-protected'}
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
{t('notificationTracker.linkPasswordProtectedNote')}
</span>
{/if}
</div>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')} {album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span> </span>
{#if album.issue === 'expired' || album.issue === 'password-protected'}
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
</button>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -6,7 +6,13 @@
testMenuOpen: string | null; testMenuOpen: string | null;
testMenuStyle: string; testMenuStyle: string;
ttTesting: Record<string, string>; ttTesting: Record<string, string>;
testTypes: { key: string; icon: string; labelKey: string }[]; /**
* When `disabledReason` is set, the button is rendered greyed out with a
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
* Summary in Tracking Config first"). Clicking is blocked — clicking an
* unconfigured test would have surfaced as a silent server-side skip.
*/
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
ontest: (ttId: number, testType: string) => void; ontest: (ttId: number, testType: string) => void;
onclose: () => void; onclose: () => void;
} }
@@ -20,18 +26,27 @@
onclick={onclose} onclick={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}> onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
</div> </div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;"> <div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
{#each testTypes as tt} {#each testTypes as tt}
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
{@const blocked = !!tt.disabledReason}
<button <button
onclick={() => ontest(Number(testMenuOpen), tt.key)} onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]} disabled={busy || blocked}
title={blocked ? t(tt.disabledReason!) : ''}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left"> class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} /> <MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)} {t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]} {#if blocked}
<MdiIcon name="mdiLock" size={12} />
{/if}
{#if busy}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span> <span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if} {/if}
</button> </button>
{#if blocked}
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -4,6 +4,7 @@
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte'; import Hint from '$lib/components/Hint.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte'; import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers'; import { getDescriptor } from '$lib/providers';
@@ -15,7 +16,7 @@
provider_id: number; provider_id: number;
collection_ids: string[]; collection_ids: string[];
scan_interval: number; scan_interval: number;
batch_duration: number; adaptive_max_skip: number | null;
default_tracking_config_id: number; default_tracking_config_id: number;
default_template_config_id: number; default_template_config_id: number;
filters: Record<string, any>; filters: Record<string, any>;
@@ -96,12 +97,12 @@
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label> <div class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} /> <EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
</div> </div>
{#if !isScheduler && colMeta && collections.length > 0} {#if !isScheduler && colMeta && collections.length > 0}
<div> <div>
<label class="block text-sm font-medium mb-1">{t(colMeta.label)}</label> <div class="block text-sm font-medium mb-1">{t(colMeta.label)}</div>
<MultiEntitySelect <MultiEntitySelect
items={collections.map(col => ({ items={collections.map(col => ({
value: col.id, value: col.id,
@@ -167,19 +168,19 @@
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button> class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
</fieldset> </fieldset>
{:else} {:else}
{#if !isWebhook}
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
{#if !isWebhook}
<div> <div>
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label> <label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
{/if}
<div> <div>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label> <label for="trk-adaptive" class="block text-sm font-medium mb-1">{t('notificationTracker.adaptiveMaxSkip')}<Hint text={t('hints.adaptiveMaxSkip')} /></label>
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="trk-adaptive" type="number" bind:value={form.adaptive_max_skip} min="0" max="10" placeholder={t('notificationTracker.adaptiveMaxSkipPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
{/if} {/if}
{/if}
<!-- Default configs --> <!-- Default configs -->
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0} {#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
@@ -199,6 +200,25 @@
</div> </div>
{/if} {/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'}
<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>
<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 mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
</div>
</div>
{/if}
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"> <button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if} {#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button> </button>
+35 -4
View File
@@ -19,6 +19,8 @@
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems }; const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { onDestroy } from 'svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers'; import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
@@ -54,7 +56,28 @@
let health = $state<Record<number, boolean | null>>({}); let health = $state<Record<number, boolean | null>>({});
onMount(load); // 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;
const checkingCount = Math.max(0, providers.length - onlineCount - offlineCount);
const typeCount = new Set(providers.map(p => p.type)).size;
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
if (onlineCount > 0) pills.push({ label: `${onlineCount} ${t('providers.online')}`, tone: 'mint' });
if (offlineCount > 0) pills.push({ label: `${offlineCount} ${t('providers.offline')}`, tone: 'coral' });
if (checkingCount > 0 && providers.length > 0) pills.push({ label: `${checkingCount} ${t('providers.checking')}`, tone: 'citrus' });
if (typeCount > 0) pills.push({ label: `${typeCount} ${typeCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
onMount(() => {
topbarAction.set({
label: t('providers.addProvider'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
async function load() { async function load() {
try { try {
await providersCache.fetch(true); await providersCache.fetch(true);
@@ -146,7 +169,15 @@
} }
</script> </script>
<PageHeader title={t('providers.title')} description={t('providers.description')}> <PageHeader
title={t('providers.title')}
emphasis={t('providers.titleEmphasis')}
description={t('providers.description')}
crumb="Service · Connections"
count={providers.length}
countLabel={t('dashboard.providersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('providers.cancel') : t('providers.addProvider')} {showForm ? t('providers.cancel') : t('providers.addProvider')}
</Button> </Button>
@@ -171,7 +202,7 @@
<ErrorBanner message={error} /> <ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3"> <form onsubmit={save} class="space-y-3">
<div> <div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label> <div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
{#if !editing} {#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} /> <IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
{:else} {:else}
@@ -216,7 +247,7 @@
{/each} {/each}
{#if descriptor?.webhookUrlPattern && editing} {#if descriptor?.webhookUrlPattern && editing}
<div class="bg-[var(--color-muted)] rounded-md p-3"> <div class="bg-[var(--color-muted)] rounded-md p-3">
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label> <div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code> <code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p> <p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div> </div>
@@ -78,7 +78,7 @@
<Card> <Card>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label> <div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} /> <IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
</div> </div>
<div> <div>
+164 -11
View File
@@ -9,26 +9,69 @@
import Hint from '$lib/components/Hint.svelte'; import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
oldest: string | null;
newest: string | null;
}
interface CacheStats {
url: CacheBucketStats;
asset: CacheBucketStats;
}
let loaded = $state(false); let loaded = $state(false);
let saving = $state(false); let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state(''); let error = $state('');
let settings = $state({ let settings = $state({
external_url: '', external_url: '',
telegram_webhook_secret: '', telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48', telegram_cache_ttl_hours: '720',
telegram_asset_cache_max_entries: '5000',
supported_locales: 'en,ru', supported_locales: 'en,ru',
timezone: 'UTC', timezone: 'UTC',
log_level: 'INFO',
log_format: 'text',
log_levels: '',
}); });
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
}
onMount(async () => { onMount(async () => {
try { try {
settings = await api('/settings'); settings = await api('/settings');
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; } finally { loaded = true; }
}); });
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() { async function save() {
saving = true; error = ''; saving = true; error = '';
try { try {
@@ -37,9 +80,25 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
saving = false; saving = false;
} }
async function clearTelegramCache() {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
}
</script> </script>
<PageHeader title={t('settings.title')} description={t('settings.description')} /> <PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
{#if !loaded} {#if !loaded}
<Loading /> <Loading />
@@ -59,9 +118,8 @@
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label> <label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<input bind:value={settings.timezone} placeholder="UTC" <TimezoneSelector bind:value={settings.timezone} />
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
</div> </div>
</Card> </Card>
@@ -75,14 +133,68 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label> <label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')} <form onsubmit={(e) => e.preventDefault()} autocomplete="off">
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> <input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div> </div>
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label> <label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720" <input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" /> class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div> </div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div> </div>
</Card> </Card>
@@ -94,9 +206,42 @@
</h3> </h3>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label> <label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr" <LocaleSelector bind:value={settings.supported_locales} />
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> </div>
</div>
</Card>
<!-- Logging section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTextBoxOutline" size={18} />
{t('settings.logging')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<select bind:value={settings.log_level}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<select bind:value={settings.log_format}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="text">text</option>
<option value="json">json</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
<input bind:value={settings.log_levels}
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
</div> </div>
</Card> </Card>
@@ -105,4 +250,12 @@
{saving ? t('common.loading') : t('common.save')} {saving ? t('common.loading') : t('common.save')}
</Button> </Button>
</div> </div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
{/if} {/if}
@@ -292,7 +292,12 @@
} }
</script> </script>
<PageHeader title={t('backup.title')} description={t('backup.description')} /> <PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
{#if !loaded} {#if !loaded}
<Loading /> <Loading />
@@ -338,7 +343,7 @@
<!-- Categories --> <!-- Categories -->
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<label class="text-xs font-medium">{t('backup.categories')}</label> <span class="text-xs font-medium">{t('backup.categories')}</span>
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}> <button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')} {allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
</button> </button>
@@ -355,7 +360,7 @@
<!-- Secrets mode --> <!-- Secrets mode -->
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</label> <div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs"> <label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="exclude" /> <input type="radio" bind:group={exportSecrets} value="exclude" />
@@ -453,7 +458,7 @@
<!-- Conflict mode --> <!-- Conflict mode -->
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</label> <div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs"> <label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="skip" /> <input type="radio" bind:group={importConflict} value="skip" />
@@ -523,8 +528,8 @@
{#if scheduledSettings.backup_scheduled_enabled === 'true'} {#if scheduledSettings.backup_scheduled_enabled === 'true'}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('backup.interval')}</label> <label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
<select bind:value={scheduledSettings.backup_scheduled_interval_hours} <select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="6">6 {t('backup.hours')}</option> <option value="6">6 {t('backup.hours')}</option>
<option value="12">12 {t('backup.hours')}</option> <option value="12">12 {t('backup.hours')}</option>
@@ -535,8 +540,8 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label> <label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
<select bind:value={scheduledSettings.backup_secrets_mode} <select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="exclude">{t('backup.secretsExclude')}</option> <option value="exclude">{t('backup.secretsExclude')}</option>
<option value="masked">{t('backup.secretsMasked')}</option> <option value="masked">{t('backup.secretsMasked')}</option>
@@ -544,8 +549,8 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium mb-1">{t('backup.retention')}</label> <label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
<select bind:value={scheduledSettings.backup_retention_count} <select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="3">3</option> <option value="3">3</option>
<option value="5">5</option> <option value="5">5</option>
@@ -651,6 +656,7 @@
onclick={() => postRestoreModalOpen = false} onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }} onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation"> role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1" <div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);" style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
onclick={(e) => e.stopPropagation()}> onclick={(e) => e.stopPropagation()}>
+27 -5
View File
@@ -6,6 +6,7 @@
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte'; import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -134,7 +135,7 @@
let loadError = $state(''); let loadError = $state('');
let showTelegramSettings = $state(false); let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null); let confirmDelete = $state<NotificationTarget | null>(null);
let formEl: HTMLElement; let formEl = $state<HTMLElement | undefined>();
async function scrollToForm() { async function scrollToForm() {
await tick(); await tick();
@@ -165,6 +166,20 @@
// ── Data loading ── // ── Data loading ──
onMount(load); onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
// Tab-filtered: show count of receivers for the active type only.
const total = targets.reduce((acc, t) => acc + (t.receiver_count || 0), 0);
if (total > 0) pills.push({ label: `${total} ${total === 1 ? t('targets.receiver') : t('targets.receivers')}`, tone: 'mint' });
} else {
const types = new Set(targets.map(t => t.type)).size;
if (types > 0) pills.push({ label: `${types} ${t('targets.channelsCount')}`, tone: 'sky' });
}
return pills;
});
async function load() { async function load() {
try { try {
await Promise.all([ await Promise.all([
@@ -418,11 +433,18 @@
} }
</script> </script>
<PageHeader title={activeType ? `${t('targets.title')} ${activeType.charAt(0).toUpperCase() + activeType.slice(1)}` : t('targets.title')} description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}> <PageHeader
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }} title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90"> emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('targets.cancel') : t('targets.addTarget')} {showForm ? t('targets.cancel') : t('targets.addTarget')}
</button> </Button>
</PageHeader> </PageHeader>
{#if !loaded}<Loading />{:else} {#if !loaded}<Loading />{:else}
@@ -79,7 +79,7 @@
<form onsubmit={onsave} class="space-y-4"> <form onsubmit={onsave} class="space-y-4">
{#if !activeType} {#if !activeType}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label> <div class="block text-sm font-medium mb-1">{t('targets.type')}</div>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} /> <IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div> </div>
{/if} {/if}
@@ -92,7 +92,7 @@
</div> </div>
{#if formType === 'telegram'} {#if formType === 'telegram'}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label> <div class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</div>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} /> <EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBotCount === 0} {#if telegramBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p> <p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
@@ -124,7 +124,7 @@
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label> <div class="block text-xs mb-1">{t('targets.chatAction')}</div>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact /> <IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div> </div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label> <label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
@@ -151,7 +151,7 @@
</div> </div>
{:else if formType === 'email'} {:else if formType === 'email'}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label> <div class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</div>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} /> <EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBotCount === 0} {#if emailBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p> <p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
@@ -159,7 +159,7 @@
</div> </div>
{:else if formType === 'matrix'} {:else if formType === 'matrix'}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label> <div class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</div>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} /> <EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBotCount === 0} {#if matrixBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p> <p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
@@ -168,7 +168,7 @@
{:else if formType === 'broadcast'} {:else if formType === 'broadcast'}
{@const childIds = (form.child_target_ids || []).map(String)} {@const childIds = (form.child_target_ids || []).map(String)}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</label> <div class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</div>
<MultiEntitySelect <MultiEntitySelect
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []} items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
values={childIds} values={childIds}
+194 -21
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte'; import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
@@ -20,6 +21,8 @@
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte'; import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -42,6 +45,17 @@
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let error = $state(''); let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null); let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
/**
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
* single-slot reset (slotKey populated); ``'all'`` confirms a full
* locale-scoped wipe. Split from confirmDelete so the two flows can
* coexist without stomping each other's state mid-dialog.
*/
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({}); let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({}); let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({}); let slotErrorLines = $state<Record<string, number | null>>({});
@@ -59,7 +73,24 @@
let showPreviewFor = $state<Set<string>>(new Set()); let showPreviewFor = $state<Set<string>>(new Set());
let LOCALES = $derived(supportedLocalesCache.items); let LOCALES = $derived(supportedLocalesCache.items);
let activeLocale = $state<string>('en'); let primaryLocale = $derived(LOCALES[0] || 'en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
* Promote primary to be the active locale once the supported-locales
* cache loads (covers initial mount before openNew/edit ran). Without
* this, opening a form before fetch resolves would stay on '' / 'en'.
*/
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
function toggleSlot(key: string) { function toggleSlot(key: string) {
const next = new Set(expandedSlots); const next = new Set(expandedSlots);
@@ -196,7 +227,22 @@
]}, ]},
]); ]);
onMount(load); onMount(() => {
topbarAction.set({
label: t('templateConfig.newConfig'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() { async function load() {
try { try {
[, varsRef] = await Promise.all([ [, varsRef] = await Promise.all([
@@ -206,13 +252,46 @@
supportedLocalesCache.fetch(), supportedLocalesCache.fetch(),
]); ]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); } finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
}
/**
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
* other pages (currently the tracking-configs Preview-template modal).
* Picks the first visible config matching ``provider``, opens it in edit
* mode, and pre-expands the target slot. Strips the param from the URL so
* a subsequent reload doesn't reopen the form unexpectedly.
*/
function handleDeepLink() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const slot = params.get('edit_slot');
if (!slot) return;
const provider = params.get('provider') || '';
const target = allTemplateConfigs.find(
c => !provider || c.provider_type === provider,
);
// Strip the deep-link param so reload/back doesn't replay it.
params.delete('edit_slot');
const qs = params.toString();
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
if (!target) {
snackError(t('templateConfig.deepLinkNoConfig'));
return;
}
edit(target);
expandedSlots = new Set([slot]);
// Scroll the slot into view once the form has rendered.
setTimeout(() => {
const el = document.getElementById(`slot-${slot}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
} }
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0]; if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview(); refreshDateFormatPreview();
} }
function edit(c: TemplateConfig) { function edit(c: TemplateConfig) {
@@ -225,7 +304,7 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC', date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y', date_only_format: c.date_only_format || '%d.%m.%Y',
}; };
editing = c.id; showForm = true; activeLocale = 'en'; editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -241,6 +320,65 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
} }
/**
* Ask the user to confirm a reset. The actual fetch+replace runs in
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
* two steps so we can use the app-wide ConfirmModal (consistent look,
* keyboard handling) instead of ``window.confirm`` (blocks the page).
*/
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
// Replace current-locale slots; leave other locales' values untouched.
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: TemplateConfig) { function clone(c: TemplateConfig) {
form = { form = {
provider_type: c.provider_type, provider_type: c.provider_type,
@@ -253,7 +391,7 @@
}; };
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -276,7 +414,15 @@
} }
</script> </script>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}> <PageHeader
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('templateConfig.newConfig')} {showForm ? t('common.cancel') : t('templateConfig.newConfig')}
</Button> </Button>
@@ -305,7 +451,7 @@
{#if !editing} {#if !editing}
<div> <div>
<label class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label> <div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} /> <IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
</div> </div>
{:else} {:else}
@@ -316,19 +462,31 @@
{/if} {/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm font-medium">{t('templateConfig.previewAs')}:</label> <span class="text-sm font-medium">{t('templateConfig.previewAs')}:</span>
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} /> <IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div> </div>
<!-- Locale tabs --> <!-- Language picker -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]"> <div class="flex items-center gap-2 mb-3">
{#each LOCALES as loc} <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
<button type="button" {t('templateConfig.language')}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}" </span>
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}> <div class="flex-1 max-w-xs">
{loc.toUpperCase()} <EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button> </button>
{/each} {/if}
</div> </div>
<!-- Slot filter --> <!-- Slot filter -->
@@ -349,9 +507,9 @@
{#if slot.isDateFormat} {#if slot.isDateFormat}
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label> <label for="datefmt-{slot.key}" class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
</div> </div>
<input value={(form as any)[slot.key]} <input id="datefmt-{slot.key}" value={(form as any)[slot.key]}
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }} oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" /> class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{#if dateFormatPreview[slot.key]} {#if dateFormatPreview[slot.key]}
@@ -361,6 +519,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div id="slot-{slot.key}">
<CollapsibleSlot <CollapsibleSlot
label={slot.key} label={slot.key}
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)} description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
@@ -379,6 +538,11 @@
<button type="button" onclick={() => showVarsFor = slot.key} <button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button> class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if} {/if}
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div> </div>
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]} {#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
@@ -391,12 +555,13 @@
{#if slotErrors[slot.key]} {#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'} {#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{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} {: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}
{/if} {/if}
</CollapsibleSlot> </CollapsibleSlot>
</div>
{/if} {/if}
{/each} {/each}
</div> </div>
@@ -466,6 +631,14 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} /> <BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- Variables reference modal -->
+278 -16
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte'; import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte'; import { trackingConfigsCache } from '$lib/stores/caches.svelte';
@@ -12,6 +13,9 @@
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import { sanitizePreview } from '$lib/sanitize';
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
import Hint from '$lib/components/Hint.svelte'; import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -22,13 +26,150 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers'; import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import type { TrackingConfig } from '$lib/types';
/** 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[]> = { const gridItemSources: Record<string, () => any[]> = {
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
}; };
/**
* 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.
*/
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
/** 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. */
function normalizeTimeList(key: string) {
const raw = String(form[key] ?? '').trim();
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
if (!TIME_LIST_RE.test(raw)) {
// Try a lenient normalization: split on commas, zero-pad each part.
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
const fixed: string[] = [];
let ok = true;
for (const p of parts) {
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
if (!m) { ok = false; break; }
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
}
if (ok) {
form[key] = fixed.join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
return;
}
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
return;
}
// Canonicalise spacing.
form[key] = raw.split(',').map(s => s.trim()).join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
}
/**
* 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 {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
const sMin = sh * 60 + sm;
const eMin = eh * 60 + em;
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
const overnight = sMin > eMin;
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
const h = Math.floor(span / 60);
const m = span % 60;
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
const arrow = overnight
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
: `${start} → ${end}`;
return `${arrow} (${dur})`;
}
function gotoTemplateConfig(slotName: string) {
// Deep-link to the template configs page: pass the slot as a query
// param (``edit_slot``) so the destination can auto-open the first
// matching config in edit mode and expand that slot. Plain hashes
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
// until a config is being edited.
const u = new URL('/template-configs', window.location.origin);
u.searchParams.set('provider', 'immich');
u.searchParams.set('edit_slot', slotName);
window.location.href = u.toString();
}
/**
* 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
* 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.
*/
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
let previewLoading = $state(false);
let previewLocales = $derived(supportedLocalesCache.items);
async function openTemplatePreview(slotName: string) {
await supportedLocalesCache.fetch();
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
await renderPreviewFor(slotName, initialLocale);
}
async function renderPreviewFor(slotName: string, locale: string) {
previewLoading = true;
try {
const defaults = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
);
const template = defaults?.[slotName]?.[locale];
if (!template) {
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
return;
}
const res = await api<{ rendered?: string; error?: string }>(
'/template-configs/preview-raw',
{
method: 'POST',
body: JSON.stringify({
template,
target_type: 'telegram',
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
}),
},
);
previewModal = {
slotName,
rendered: res?.rendered || '',
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} finally {
previewLoading = false;
}
}
const SLOT_FOR_SECTION: Record<string, string> = {
periodic: 'periodic_summary_message',
scheduled: 'scheduled_assets_message',
memory: 'memory_mode_message',
};
let allConfigs = $derived(trackingConfigsCache.items); let allConfigs = $derived(trackingConfigsCache.items);
let filterText = $state(''); let filterText = $state('');
let filterType = $state(''); let filterType = $state('');
@@ -50,11 +191,43 @@
let form: Record<string, any> = $state(defaultForm()); let form: Record<string, any> = $state(defaultForm());
let descriptor = $derived(getDescriptor(form.provider_type)); let descriptor = $derived(getDescriptor(form.provider_type));
onMount(load); onMount(() => {
topbarAction.set({
label: t('trackingConfig.newConfig'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() { async function load() {
try { await trackingConfigsCache.fetch(true); } try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); } finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
}
// Cross-page deep-link: ``/tracking-configs?edit=<id>`` auto-opens that
// config in edit mode. Used by the Notification Tracker form's "Open
// Tracking Config" link so users land directly on the right editor
// instead of the generic list. Strips the param afterwards so a browser
// refresh doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
} }
function openNew() { form = defaultForm(); editing = null; showForm = true; } function openNew() { form = defaultForm(); editing = null; showForm = true; }
@@ -90,7 +263,15 @@
} }
</script> </script>
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}> <PageHeader
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}> <Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')} {showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
</Button> </Button>
@@ -113,7 +294,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</label> <div class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</div>
{#if !editing} {#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} /> <IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
{:else} {:else}
@@ -161,10 +342,20 @@
{t(section.legend)} {t(section.legend)}
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if} {#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
</legend> </legend>
<label class="flex items-center gap-2 text-sm mt-1"> <div class="flex items-center justify-between mt-1">
<input type="checkbox" bind:checked={form[section.enabledField]} /> <label class="flex items-center gap-2 text-sm">
{t('trackingConfig.enabled')} <input type="checkbox" bind:checked={form[section.enabledField]} />
</label> {t('trackingConfig.enabled')}
</label>
{#if SLOT_FOR_SECTION[section.key]}
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
disabled={previewLoading}>
<MdiIcon name="mdiEyeOutline" size={14} />
{t('trackingConfig.previewTemplate')}
</button>
{/if}
</div>
{#if form[section.enabledField]} {#if form[section.enabledField]}
<div class="grid grid-cols-3 gap-3 mt-3"> <div class="grid grid-cols-3 gap-3 mt-3">
{#each section.fields as field (field.key)} {#each section.fields as field (field.key)}
@@ -181,17 +372,32 @@
{:else if field.type === 'grid-select' && field.gridItems} {:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact /> <IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else} {:else}
<input type={field.key.includes('date') ? 'date' {@const inputType = field.type === 'date' ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time' : field.type === 'time' ? 'time'
: field.key.includes('times') ? 'text' : field.type === 'time-list' ? 'text'
: 'number'} : 'number'}
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max} bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''} onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{#if hasError}
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
{/if}
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiWeatherNight" size={12} />
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
</p>
{/if}
{/if} {/if}
</fieldset> </fieldset>
{/each} {/each}
@@ -268,7 +474,63 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} /> <BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<Modal open={previewModal !== null}
title={previewModal ? `${t('trackingConfig.previewTemplate')} ${previewModal.slotName}` : ''}
onclose={() => previewModal = null}>
{#if previewModal}
{#if previewLocales.length > 1}
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each previewLocales as loc}
<button type="button"
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
disabled={previewLoading}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
{loc.toUpperCase()}
</button>
{/each}
</div>
{/if}
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
{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 "…"
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;">
{#if previewModal.error}
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
{previewModal.error}
</div>
{:else if previewModal.rendered}
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
<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>
{/if}
</div>
<div class="flex gap-2 justify-end mt-3">
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
{t('trackingConfig.editTemplate')}
</button>
<button type="button" onclick={() => previewModal = null}
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
{t('common.close')}
</button>
</div>
{/if}
</Modal>
<style> <style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
.toggle-switch { .toggle-switch {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
+8 -1
View File
@@ -89,7 +89,14 @@
} }
</script> </script>
<PageHeader title={t('users.title')} description={t('users.description')}> <PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}> <Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')} {showForm ? t('users.cancel') : t('users.addUser')}
</Button> </Button>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.2.3" version = "0.6.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -0,0 +1,66 @@
"""Request-scoped ContextVars that propagate into log records.
The server sets these at entry points (Telegram webhook, scheduler dispatch,
REST call) and they propagate through async calls automatically. A
``LogRecordFactory`` installed by ``notify_bridge_server.logging_setup``
reads them so every log line is tagged (``request_id``, ``command``,
``chat_id``, ``bot_id``, ``dispatch_id``) without each call site having
to pass the values explicitly.
Kept in ``notify_bridge_core`` so core modules (``TelegramClient``,
``NotificationDispatcher``) can *set* additional context (e.g. a
``dispatch_id``) without depending on the server package.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token
from typing import Any, Iterator
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
command_var: ContextVar[str | None] = ContextVar("command", default=None)
chat_id_var: ContextVar[str | None] = ContextVar("chat_id", default=None)
bot_id_var: ContextVar[int | None] = ContextVar("bot_id", default=None)
dispatch_id_var: ContextVar[str | None] = ContextVar("dispatch_id", default=None)
_VAR_MAP: dict[str, ContextVar[Any]] = {
"request_id": request_id_var,
"command": command_var,
"chat_id": chat_id_var,
"bot_id": bot_id_var,
"dispatch_id": dispatch_id_var,
}
@contextmanager
def bind_log_context(**kwargs: Any) -> Iterator[None]:
"""Bind the given context fields for the duration of the ``with`` block.
Unknown keys are ignored so callers can pass whatever they want without
an ``if`` ladder. Values are reset on exit even if the block raises.
Example:
``with bind_log_context(request_id="abc", command="random"): ...``
"""
tokens: list[tuple[ContextVar[Any], Token]] = []
try:
for key, value in kwargs.items():
var = _VAR_MAP.get(key)
if var is None:
continue
tokens.append((var, var.set(value)))
yield
finally:
for var, tok in tokens:
var.reset(tok)
def current_log_context() -> dict[str, Any]:
"""Return a snapshot of the currently-bound context values (non-None)."""
snap: dict[str, Any] = {}
for key, var in _VAR_MAP.items():
val = var.get()
if val is not None:
snap[key] = val
return snap
@@ -52,22 +52,46 @@ class DiscordClient:
return {"success": True} return {"success": True}
_MAX_RETRIES = 3
_MAX_RETRY_AFTER = 60.0
async def _post(self, url: str, payload: dict) -> dict[str, Any]: async def _post(self, url: str, payload: dict) -> dict[str, Any]:
try: """POST with bounded 429 retry.
async with self._session.post(
url, json=payload, headers={"Content-Type": "application/json"} We cap retries at _MAX_RETRIES and the ``Retry-After`` header at
) as resp: _MAX_RETRY_AFTER seconds so a hostile or misbehaving upstream cannot
if resp.status == 429: pin the dispatch task indefinitely.
retry_after = float(resp.headers.get("Retry-After", "2")) """
_LOGGER.warning("Discord rate limited, retrying after %.1fs", retry_after) for attempt in range(self._MAX_RETRIES + 1):
await asyncio.sleep(retry_after) try:
return await self._post(url, payload) async with self._session.post(
if 200 <= resp.status < 300: url,
return {"success": True} json=payload,
body = await resp.text() headers={"Content-Type": "application/json"},
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"} allow_redirects=False,
except aiohttp.ClientError as e: ) as resp:
return {"success": False, "error": str(e)} if resp.status == 429 and attempt < self._MAX_RETRIES:
try:
retry_after = float(resp.headers.get("Retry-After", "2"))
except (TypeError, ValueError):
retry_after = 2.0
retry_after = max(0.0, min(retry_after, self._MAX_RETRY_AFTER))
_LOGGER.warning(
"Discord rate limited, retrying after %.1fs (attempt %d/%d)",
retry_after, attempt + 1, self._MAX_RETRIES,
)
await asyncio.sleep(retry_after)
continue
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {
"success": False,
"error": f"HTTP {resp.status}: {body[:200]}",
}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": "Rate limited (retries exhausted)"}
def _split_message(text: str, limit: int) -> list[str]: def _split_message(text: str, limit: int) -> list[str]:
@@ -3,16 +3,19 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import logging import logging
import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, AsyncIterator
import aiohttp import aiohttp
from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.templates.context import build_template_context from notify_bridge_core.templates.context import build_template_context
from notify_bridge_core.templates.renderer import render_template from notify_bridge_core.templates.renderer import render_template
from .ssrf import UnsafeURLError, validate_outbound_url from .ssrf import UnsafeURLError, avalidate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30) _HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
@@ -46,6 +49,7 @@ from .receiver import (
from .telegram.cache import TelegramFileCache from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient from .telegram.client import TelegramClient
from .telegram.media import ( from .telegram.media import (
build_telegram_asset_entry,
extract_asset_id_from_url, extract_asset_id_from_url,
is_asset_cache_key, is_asset_cache_key,
is_asset_id, is_asset_id,
@@ -81,9 +85,28 @@ class NotificationDispatcher:
*, *,
url_cache: TelegramFileCache | None = None, url_cache: TelegramFileCache | None = None,
asset_cache: TelegramFileCache | None = None, asset_cache: TelegramFileCache | None = None,
session: aiohttp.ClientSession | None = None,
) -> None: ) -> None:
self._url_cache = url_cache self._url_cache = url_cache
self._asset_cache = asset_cache self._asset_cache = asset_cache
# Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session
# (saves a TLS handshake per outbound call).
self._shared_session = session
@contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
"""Yield an aiohttp session, reusing the shared one if provided.
When a shared session was passed in ``__init__`` we yield it without
closing (the caller owns its lifetime). Otherwise we open a
short-lived session with our default timeout and close it on exit.
"""
if self._shared_session is not None and not self._shared_session.closed:
yield self._shared_session
return
async with _new_session() as session:
yield session
async def dispatch( async def dispatch(
self, self,
@@ -94,18 +117,40 @@ class NotificationDispatcher:
Returns list of results (one per target). Returns list of results (one per target).
""" """
raw_results = await asyncio.gather( # Bind a dispatch_id so every log line emitted by the target sends
*[self._send_to_target(event, t) for t in targets], # (including deep in TelegramClient) can be correlated to the same
return_exceptions=True, # upstream event.
) new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
results = []
for raw in raw_results: with bind_log_context(dispatch_id=new_id):
if isinstance(raw, Exception): _LOGGER.info(
_LOGGER.error("Failed to dispatch to target: %s", raw) "Dispatching event %s (collection=%r) to %d target(s)",
results.append({"success": False, "error": str(raw)}) event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
else: getattr(event, "collection_name", None), len(targets),
results.append(raw) )
return results raw_results = await asyncio.gather(
*[self._send_to_target(event, t) for t in targets],
return_exceptions=True,
)
results = []
failures = 0
for target, raw in zip(targets, raw_results):
if isinstance(raw, Exception):
failures += 1
_LOGGER.error(
"Dispatch to target type=%s failed: %s",
target.type, raw, exc_info=raw,
)
results.append({"success": False, "error": str(raw)})
else:
if isinstance(raw, dict) and not raw.get("success"):
failures += 1
results.append(raw)
_LOGGER.info(
"Dispatch finished: %d target(s), %d failure(s)",
len(targets), failures,
)
return results
def _resolve_template( def _resolve_template(
self, event: ServiceEvent, target: TargetConfig, locale: str, self, event: ServiceEvent, target: TargetConfig, locale: str,
@@ -266,38 +311,48 @@ class NotificationDispatcher:
# Prefer internal URL for fetching (LAN speed vs public internet) # Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/") internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/") external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u]
assets = [] assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]: for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url: asset_entry = build_telegram_asset_entry(
# Rewrite external URL to internal for faster LAN fetching url=url or "",
if internal_url and external_url and url.startswith(external_url): media_type=asset.type.value,
url = internal_url + url[len(external_url):] api_key=target.provider_api_key,
asset_type = "video" if asset.type.value == "video" else "photo" internal_url=internal_url,
asset_headers = {} external_url=external_url,
if target.provider_api_key and any(url.startswith(u) for u in provider_urls): cache_key=asset.extra.get("cache_key"),
asset_headers["x-api-key"] = target.provider_api_key )
asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers} if asset_entry is not None:
# Pass explicit cache_key if set by provider (e.g. Google Photos)
if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry) assets.append(asset_entry)
media_assets.append(asset) media_assets.append(asset)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its # Preload all asset bytes once so (a) TelegramClient can skip its
# own download and (b) we know exact upload sizes in time for the # own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text. # oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size) await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale) default_message = self._render_message(event, target, target.locale)
# Asset cache (when in thumbhash mode) invalidates entries when the
# asset's visual content changes. The resolver maps asset id → its
# current thumbhash. Providers that expose thumbhash put it in
# ``asset.extra["thumbhash"]`` (currently Immich).
thumbhash_map = {
asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets
if asset.extra.get("thumbhash")
}
thumbhash_resolver = (
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
client = TelegramClient( client = TelegramClient(
session, bot_token, session, bot_token,
url_cache=self._url_cache, url_cache=self._url_cache,
asset_cache=self._asset_cache, asset_cache=self._asset_cache,
thumbhash_resolver=thumbhash_resolver,
) )
for receiver in target.receivers: for receiver in target.receivers:
@@ -343,13 +398,13 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
for receiver in target.receivers: for receiver in target.receivers:
if not isinstance(receiver, WebhookReceiver) or not receiver.url: if not isinstance(receiver, WebhookReceiver) or not receiver.url:
results.append({"success": False, "error": "Invalid webhook receiver"}) results.append({"success": False, "error": "Invalid webhook receiver"})
continue continue
try: try:
validate_outbound_url(receiver.url) await avalidate_outbound_url(receiver.url)
except UnsafeURLError as err: except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"}) results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue continue
@@ -417,14 +472,14 @@ class NotificationDispatcher:
username = target.config.get("username") username = target.config.get("username")
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
client = DiscordClient(session) client = DiscordClient(session)
for receiver in target.receivers: for receiver in target.receivers:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url: if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid discord receiver"}) results.append({"success": False, "error": "Invalid discord receiver"})
continue continue
try: try:
validate_outbound_url(receiver.webhook_url) await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err: except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"}) results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue continue
@@ -443,14 +498,14 @@ class NotificationDispatcher:
username = target.config.get("username") username = target.config.get("username")
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
client = SlackClient(session) client = SlackClient(session)
for receiver in target.receivers: for receiver in target.receivers:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url: if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid slack receiver"}) results.append({"success": False, "error": "Invalid slack receiver"})
continue continue
try: try:
validate_outbound_url(receiver.webhook_url) await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err: except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"}) results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue continue
@@ -469,14 +524,14 @@ class NotificationDispatcher:
if not target.receivers: if not target.receivers:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
try: try:
validate_outbound_url(server_url) await avalidate_outbound_url(server_url)
except UnsafeURLError as err: except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"} return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
title = f"{event.event_type.value}: {event.collection_name}" title = f"{event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
client = NtfyClient(session) client = NtfyClient(session)
for receiver in target.receivers: for receiver in target.receivers:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic: if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
@@ -500,7 +555,7 @@ class NotificationDispatcher:
if not homeserver or not access_token: if not homeserver or not access_token:
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"} return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
try: try:
validate_outbound_url(homeserver) await avalidate_outbound_url(homeserver)
except UnsafeURLError as err: except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"} return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
@@ -508,7 +563,7 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with self._session_ctx() as session:
client = MatrixClient(session, homeserver, access_token) client = MatrixClient(session, homeserver, access_token)
for receiver in target.receivers: for receiver in target.receivers:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id: if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
@@ -68,7 +68,9 @@ class MatrixClient:
} }
try: try:
async with self._session.put(url, json=body, headers=headers) as resp: async with self._session.put(
url, json=body, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300: if 200 <= resp.status < 300:
return {"success": True} return {"success": True}
resp_body = await resp.text() resp_body = await resp.text()
@@ -51,7 +51,9 @@ class NtfyClient:
headers["Authorization"] = f"Bearer {auth_token}" headers["Authorization"] = f"Bearer {auth_token}"
try: try:
async with self._session.post(url, json=payload, headers=headers) as resp: async with self._session.post(
url, json=payload, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300: if 200 <= resp.status < 300:
return {"success": True} return {"success": True}
body = await resp.text() body = await resp.text()
@@ -38,6 +38,7 @@ class SlackClient:
webhook_url, webhook_url,
json=payload, json=payload,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
allow_redirects=False,
) as resp: ) as resp:
if resp.status == 429: if resp.status == 429:
_LOGGER.warning("Slack rate limited") _LOGGER.warning("Slack rate limited")
@@ -12,14 +12,25 @@ development against localhost services.
from __future__ import annotations from __future__ import annotations
import asyncio
import ipaddress import ipaddress
import logging
import os import os
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
_LOGGER = logging.getLogger(__name__)
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1" _ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
_ALLOWED_SCHEMES = {"http", "https"} _ALLOWED_SCHEMES = {"http", "https"}
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
_LOGGER.warning(
"SSRF guard: private-URL bypass ENABLED "
"(NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1). Requests to RFC1918 / "
"loopback / link-local hosts will be permitted."
)
class UnsafeURLError(ValueError): class UnsafeURLError(ValueError):
"""Raised when a URL targets a disallowed network destination.""" """Raised when a URL targets a disallowed network destination."""
@@ -36,13 +47,7 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
) )
def validate_outbound_url(url: str) -> str: def _check_scheme_host(url: str) -> tuple[str, str]:
"""Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
private addresses are permitted but the scheme check still applies.
"""
if not isinstance(url, str) or not url: if not isinstance(url, str) or not url:
raise UnsafeURLError("URL is empty") raise UnsafeURLError("URL is empty")
parsed = urlparse(url) parsed = urlparse(url)
@@ -51,6 +56,31 @@ def validate_outbound_url(url: str) -> str:
host = parsed.hostname host = parsed.hostname
if not host: if not host:
raise UnsafeURLError("URL has no host") raise UnsafeURLError("URL has no host")
return parsed.scheme, host
def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
for info in infos:
sockaddr = info[4]
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError:
continue
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
def validate_outbound_url(url: str) -> str:
"""Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
private addresses are permitted but the scheme check still applies.
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
:func:`avalidate_outbound_url` from async code paths.
"""
_, host = _check_scheme_host(url)
if _ALLOW_PRIVATE: if _ALLOW_PRIVATE:
return url return url
@@ -64,17 +94,37 @@ def validate_outbound_url(url: str) -> str:
except ValueError: except ValueError:
pass pass
# Hostname — resolve and reject if any resolution is in a blocked range.
try: try:
infos = socket.getaddrinfo(host, None) infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc: except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
for info in infos: _check_resolved_addresses(host, infos)
sockaddr = info[4] return url
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError: async def avalidate_outbound_url(url: str) -> str:
continue """Async variant that resolves DNS via the running loop's resolver.
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}") Use this from ``async def`` code paths to avoid blocking the event
loop on DNS lookups.
"""
_, host = _check_scheme_host(url)
if _ALLOW_PRIVATE:
return url
try:
ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range")
return url
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
infos = await loop.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
_check_resolved_addresses(host, infos)
return url return url
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60 DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
class TelegramFileCache: class TelegramFileCache:
"""Cache for Telegram file_ids to avoid re-uploading media. """Cache for Telegram file_ids to avoid re-uploading media.
Supports two validation modes: Two complementary invalidation strategies, usable together or separately:
- TTL mode (default): entries expire after a configured time-to-live - TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
- Thumbhash mode: entries validated by comparing stored thumbhash with current (cache essentially forever, subject only to the size cap).
""" - Thumbhash mode: entries are validated on read by comparing the stored
thumbhash with the one the caller supplies; a mismatch drops the entry.
Intended for content-addressable assets (e.g. Immich) where re-uploads
should be triggered by visual change, not elapsed time.
THUMBHASH_MAX_ENTRIES = 2000 ``max_entries`` always applies as an LRU size cap (by ``cached_at``).
"""
def __init__( def __init__(
self, self,
backend: StorageBackend, backend: StorageBackend,
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL, ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
use_thumbhash: bool = False, use_thumbhash: bool = False,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None: ) -> None:
self._backend = backend self._backend = backend
self._data: dict[str, Any] | None = None self._data: dict[str, Any] | None = None
self._ttl_seconds = ttl_seconds self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
async def async_load(self) -> None: async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}} self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired() await self._cleanup_expired()
async def _cleanup_expired(self) -> None: async def _cleanup_expired(self) -> None:
if self._use_thumbhash:
files = self._data.get("files", {}) if self._data else {}
if len(files) > self.THUMBHASH_MAX_ENTRIES:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
del files[key]
await self._backend.save(self._data)
return
if not self._data or "files" not in self._data: if not self._data or "files" not in self._data:
return return
files = self._data["files"]
changed = False
now = datetime.now(timezone.utc) # TTL sweep — only when TTL validation is active (i.e. no thumbhash
expired = [ # mode and a positive TTL). In thumbhash mode we rely entirely on
url for url, entry in self._data["files"].items() # content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
if entry.get("cached_at") and # cache forever, subject only to the size cap.
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds if not self._use_thumbhash and self._ttl_seconds > 0:
] now = datetime.now(timezone.utc)
expired = [
if expired: url for url, entry in files.items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
for key in expired: for key in expired:
del self._data["files"][key] del files[key]
changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
if self._max_entries > 0 and len(files) > self._max_entries:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self._max_entries]:
del files[key]
changed = True
if changed:
await self._backend.save(self._data) await self._backend.save(self._data)
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None: def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
@@ -77,7 +90,7 @@ class TelegramFileCache:
if stored and stored != thumbhash: if stored and stored != thumbhash:
del self._data["files"][key] del self._data["files"][key]
return None return None
else: elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at") cached_at_str = entry.get("cached_at")
if cached_at_str: if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds() age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
@@ -152,3 +165,32 @@ class TelegramFileCache:
async def async_remove(self) -> None: async def async_remove(self) -> None:
await self._backend.remove() await self._backend.remove()
self._data = None self._data = None
def stats(self) -> dict[str, Any]:
"""Return summary stats about the current cache contents.
Includes the number of cached entries, total tracked size in bytes
(only counts entries with a recorded ``size``), and the oldest /
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
"""
files = self._data.get("files", {}) if self._data else {}
count = len(files)
total_size = 0
oldest: str | None = None
newest: str | None = None
for entry in files.values():
size = entry.get("size")
if isinstance(size, int):
total_size += size
cached_at = entry.get("cached_at")
if cached_at:
if oldest is None or cached_at < oldest:
oldest = cached_at
if newest is None or cached_at > newest:
newest = cached_at
return {
"count": count,
"total_size_bytes": total_size,
"oldest": oldest,
"newest": newest,
}
@@ -89,6 +89,18 @@ class TelegramClient:
self, url: str | None, cache_key: str | None = None, self, url: str | None, cache_key: str | None = None,
) -> tuple[TelegramFileCache | None, str | None, str | None]: ) -> tuple[TelegramFileCache | None, str | None, str | None]:
if cache_key: if cache_key:
# Route asset-UUID cache keys to the asset cache so single-item
# sends hit the same cache the media-group path uses. Without
# this, a command returning one photo stored file_ids in the
# URL cache and a command returning multiple stored them in
# the asset cache — repeated sends never hit.
if is_asset_cache_key(cache_key):
bare_id = asset_id_from_cache_key(cache_key)
thumbhash = (
self._thumbhash_resolver(bare_id)
if self._thumbhash_resolver else None
)
return self._asset_cache, cache_key, thumbhash
return self._url_cache, cache_key, None return self._url_cache, cache_key, None
if url: if url:
if is_asset_id(url): if is_asset_id(url):
@@ -150,8 +162,20 @@ class TelegramClient:
"message_id": result.get("result", {}).get("message_id"), "message_id": result.get("result", {}).get("message_id"),
"cached": True, "cached": True,
} }
except aiohttp.ClientError: # Non-ok from a cached send — file_id stale or file deleted on
pass # Telegram's side. Log at DEBUG so operators who are hunting
# "why didn't the cached send work?" can see it, but the
# caller will fall through to a fresh upload.
_LOGGER.debug(
"Telegram %s (cached) returned non-ok: status=%s code=%s desc=%r — falling back to fresh upload",
kind.api_method, response.status, result.get("error_code"),
result.get("description"),
)
except aiohttp.ClientError as err:
_LOGGER.debug(
"Telegram %s (cached) transport error — falling back to fresh upload: %s",
kind.api_method, err,
)
return None return None
async def _upload_media( async def _upload_media(
@@ -191,8 +215,17 @@ class TelegramClient:
thumbhash=thumbhash, size=len(data), thumbhash=thumbhash, size=len(data),
) )
return {"success": True, "message_id": res.get("message_id")} return {"success": True, "message_id": res.get("message_id")}
_LOGGER.error(
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
kind.api_method, response.status, result.get("error_code"),
result.get("description", "Unknown"), len(data),
)
return {"success": False, "error": result.get("description", "Unknown Telegram error")} return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram %s transport error (bytes=%d): %s",
kind.api_method, len(data), err, exc_info=True,
)
return {"success": False, "error": str(err)} return {"success": False, "error": str(err)}
async def send_notification( async def send_notification(
@@ -217,7 +250,7 @@ class TelegramClient:
typing_task = None typing_task = None
if chat_action: if chat_action:
typing_task = self._start_typing_indicator(chat_id, chat_action) typing_task = self.start_chat_action_keepalive(chat_id, chat_action)
try: try:
if len(assets) == 1 and assets[0].get("type") == "photo": if len(assets) == 1 and assets[0].get("type") == "photo":
@@ -315,8 +348,14 @@ class TelegramClient:
retry_result = await retry_resp.json() retry_result = await retry_resp.json()
if retry_resp.status == 200 and retry_result.get("ok"): if retry_resp.status == 200 and retry_result.get("ok"):
return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")} return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")}
_LOGGER.error(
"Telegram sendMessage failed: status=%s code=%s desc=%r",
response.status, result.get("error_code"),
result.get("description", "Unknown"),
)
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")} return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.error("Telegram sendMessage transport error: %s", err, exc_info=True)
return {"success": False, "error": str(err)} return {"success": False, "error": str(err)}
async def send_chat_action(self, chat_id: str, action: str = "typing") -> bool: async def send_chat_action(self, chat_id: str, action: str = "typing") -> bool:
@@ -328,7 +367,13 @@ class TelegramClient:
except aiohttp.ClientError: except aiohttp.ClientError:
return False return False
def _start_typing_indicator(self, chat_id: str, action: str = "typing") -> asyncio.Task: def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
Telegram chat actions expire after ~5s, so callers that want the hint
to persist through longer work (fetching assets, multi-chunk uploads)
need a keep-alive. Cancel the task in a ``finally`` to stop it.
"""
async def action_loop() -> None: async def action_loop() -> None:
try: try:
while True: while True:
@@ -495,11 +540,14 @@ class TelegramClient:
# Tuple is (cache_key, media_type, thumbhash, uploaded_size). # Tuple is (cache_key, media_type, thumbhash, uploaded_size).
media_cache_info: list[tuple[str, str, str | None, int] | None] = [] media_cache_info: list[tuple[str, str, str | None, int] | None] = []
# Resolve cache hits and collect download tasks in parallel # Resolve cache hits and collect download tasks in parallel.
# Each drop site logs the reason — otherwise a filtered asset
# disappears silently and the media group silently shrinks.
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]: async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
"""Return (index, cache_entry_or_None, downloaded_bytes_or_None).""" """Return (index, cache_entry_or_None, downloaded_bytes_or_None)."""
url = item.get("url") url = item.get("url")
if not url: if not url:
_LOGGER.warning("Media skipped: missing url (idx=%d type=%s)", idx, item.get("type"))
return idx, None, None return idx, None, None
media_type = item.get("type", "photo") media_type = item.get("type", "photo")
custom_cache_key = item.get("cache_key") custom_cache_key = item.get("cache_key")
@@ -519,12 +567,24 @@ class TelegramClient:
if preloaded is not None: if preloaded is not None:
data = preloaded data = preloaded
if max_asset_data_size and len(data) > max_asset_data_size: if max_asset_data_size and len(data) > max_asset_data_size:
_LOGGER.warning(
"Media skipped: preloaded size %d exceeds max_asset_data_size %d (idx=%d type=%s url=%s)",
len(data), max_asset_data_size, idx, media_type, url,
)
return idx, None, None return idx, None, None
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE: if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
_LOGGER.warning(
"Media skipped: preloaded video %d bytes exceeds Telegram limit %d (idx=%d url=%s)",
len(data), TELEGRAM_MAX_VIDEO_SIZE, idx, url,
)
return idx, None, None return idx, None, None
if media_type == "photo": if media_type == "photo":
exceeds, _, _, _ = check_photo_limits(data) exceeds, reason, _, _ = check_photo_limits(data)
if exceeds: if exceeds:
_LOGGER.warning(
"Media skipped: preloaded photo %s (idx=%d url=%s)",
reason, idx, url,
)
return idx, None, None return idx, None, None
return idx, None, data return idx, None, data
@@ -533,18 +593,38 @@ class TelegramClient:
dl_headers = item.get("headers") or {} dl_headers = item.get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp: async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200: if resp.status != 200:
_LOGGER.warning(
"Media skipped: download HTTP %d (idx=%d type=%s url=%s)",
resp.status, idx, media_type, url,
)
return idx, None, None return idx, None, None
data = await resp.read() data = await resp.read()
if max_asset_data_size and len(data) > max_asset_data_size: if max_asset_data_size and len(data) > max_asset_data_size:
_LOGGER.warning(
"Media skipped: downloaded size %d exceeds max_asset_data_size %d (idx=%d type=%s url=%s)",
len(data), max_asset_data_size, idx, media_type, url,
)
return idx, None, None return idx, None, None
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE: if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
_LOGGER.warning(
"Media skipped: video %d bytes exceeds Telegram %d-byte limit (idx=%d url=%s)",
len(data), TELEGRAM_MAX_VIDEO_SIZE, idx, url,
)
return idx, None, None return idx, None, None
if media_type == "photo": if media_type == "photo":
exceeds, _, _, _ = check_photo_limits(data) exceeds, reason, _, _ = check_photo_limits(data)
if exceeds: if exceeds:
_LOGGER.warning(
"Media skipped: photo %s (idx=%d url=%s)",
reason, idx, url,
)
return idx, None, None return idx, None, None
return idx, None, data return idx, None, data
except aiohttp.ClientError: except aiohttp.ClientError as err:
_LOGGER.warning(
"Media skipped: download failed (idx=%d type=%s url=%s): %s",
idx, media_type, url, err,
)
return idx, None, None return idx, None, None
results = await asyncio.gather( results = await asyncio.gather(
@@ -584,6 +664,14 @@ class TelegramClient:
media_json.append(mij) media_json.append(mij)
if not media_json: if not media_json:
# Every asset in this chunk was filtered out (size, download
# failure, etc.). Without this log, sendMediaGroup returns
# success=True with zero message_ids and nobody knows why
# the user sees only the text reply and no media.
_LOGGER.warning(
"sendMediaGroup skipped — chunk %d/%d had %d input items but 0 usable (all filtered/failed)",
chunk_idx + 1, len(chunks), len(chunk),
)
continue continue
form.add_field("media", json.dumps(media_json)) form.add_field("media", json.dumps(media_json))
@@ -620,10 +708,35 @@ class TelegramClient:
if eff_cache: if eff_cache:
await eff_cache.async_set_many(cache_entries) await eff_cache.async_set_many(cache_entries)
else: else:
return {"success": False, "error": result.get("description", "Unknown"), "failed_at_chunk": chunk_idx + 1} _LOGGER.error(
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
response.status, result.get("error_code"),
result.get("description", "Unknown"),
chunk_idx + 1, len(chunks), len(media_json),
)
return {
"success": False,
"error": result.get("description", "Unknown"),
"error_code": result.get("error_code"),
"failed_at_chunk": chunk_idx + 1,
}
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
chunk_idx + 1, len(chunks), len(media_json), err,
exc_info=True,
)
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1} return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
# Distinguish "posted something" from "posted nothing" so the caller
# can surface an ERROR when a command produced a caption reply but no
# media ever reached Telegram.
if not all_message_ids:
_LOGGER.warning(
"sendMediaGroup completed with 0 message_ids across %d chunk(s) — nothing was delivered",
len(chunks),
)
return {"success": False, "error": "no_items_delivered", "chunks_sent": len(chunks)}
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)} return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -716,12 +829,41 @@ class TelegramClient:
async def set_my_commands( async def set_my_commands(
self, commands: list[dict[str, str]], language_code: str | None = None, self, commands: list[dict[str, str]], language_code: str | None = None,
scope: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Register bot commands with BotFather API.""" """Register bot commands with BotFather API.
``scope`` is a Telegram BotCommandScope object (e.g.
``{"type": "chat", "chat_id": 123}``). When provided, the
registration applies only to that scope. ``language_code`` and
``scope`` may be combined to localize per-scope.
"""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands" url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands"
payload: dict[str, Any] = {"commands": commands} payload: dict[str, Any] = {"commands": commands}
if language_code: if language_code:
payload["language_code"] = language_code payload["language_code"] = language_code
if scope:
payload["scope"] = scope
try:
async with self._session.post(url, json=payload) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def delete_my_commands(
self, language_code: str | None = None,
scope: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Clear bot commands for the given scope/language via BotFather API."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/deleteMyCommands"
payload: dict[str, Any] = {}
if language_code:
payload["language_code"] = language_code
if scope:
payload["scope"] = scope
try: try:
async with self._session.post(url, json=payload) as resp: async with self._session.post(url, json=payload) as resp:
data = await resp.json() data = await resp.json()
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Final from typing import Any, Final
from urllib.parse import urlparse from urllib.parse import urlparse
# Telegram constants # Telegram constants
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
return None return None
def build_telegram_asset_entry(
*,
url: str,
media_type: str,
api_key: str | None = None,
internal_url: str = "",
external_url: str = "",
cache_key: str | None = None,
) -> dict[str, Any] | None:
"""Build a ``TelegramClient.send_notification`` asset dict from raw fields.
Shared by the notification dispatcher and provider command handlers so
both paths agree on media typing, URL rewriting, and auth headers. In
particular: video assets MUST be typed ``"video"`` and point at a real
video endpoint (e.g. Immich ``/video/playback``) if they are sent as
``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
for every video in a media group and the user sees a dead poster frame
instead of a playable clip.
Args:
url: Source URL for the asset bytes. Prefer a transcoded/preview
URL for videos (``/video/playback``) and a preview-sized
thumbnail for photos.
media_type: Case-insensitive type token. Accepts ``"video"``/
``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
served by one of the provider hosts in ``internal_url`` /
``external_url`` (prevents leaking the key to unrelated hosts).
internal_url: LAN-facing provider URL. Used to rewrite
``external_url`` prefixes so Docker-host downloads stay on the
LAN instead of egressing to the public domain.
external_url: Public provider URL the notification URL was built
from. Only used for the LAN rewrite and the api-key scope check.
cache_key: Optional explicit cache key. Providers whose URLs don't
embed a stable asset id (Google Photos) pass one through so the
file_id cache still works.
Returns ``None`` iff ``url`` is empty.
"""
if not url:
return None
if internal_url and external_url and url.startswith(external_url):
url = internal_url + url[len(external_url):]
normalized_type = str(media_type or "").lower()
entry_type = "video" if normalized_type == "video" else "photo"
headers: dict[str, str] = {}
provider_urls = [u for u in (internal_url, external_url) if u]
if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
headers["x-api-key"] = api_key
entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
if cache_key:
entry["cache_key"] = cache_key
return entry
def split_media_by_upload_size( def split_media_by_upload_size(
media_items: list[tuple], max_upload_size: int media_items: list[tuple], max_upload_size: int
) -> list[list[tuple]]: ) -> list[list[tuple]]:
@@ -7,7 +7,7 @@ from typing import Any
import aiohttp import aiohttp
from ..ssrf import UnsafeURLError, validate_outbound_url from ..ssrf import UnsafeURLError, avalidate_outbound_url
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ class WebhookClient:
async def send(self, payload: dict[str, Any]) -> dict[str, Any]: async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
try: try:
validate_outbound_url(self._url) await avalidate_outbound_url(self._url)
except UnsafeURLError as err: except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe URL: {err}"} return {"success": False, "error": f"Unsafe URL: {err}"}
try: try:
@@ -33,6 +33,7 @@ class WebhookClient:
json=payload, json=payload,
headers={"Content-Type": "application/json", **self._headers}, headers={"Content-Type": "application/json", **self._headers},
timeout=_DEFAULT_TIMEOUT, timeout=_DEFAULT_TIMEOUT,
allow_redirects=False,
) as response: ) as response:
if 200 <= response.status < 300: if 200 <= response.status < 300:
return {"success": True, "status_code": response.status} return {"success": True, "status_code": response.status}
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
needs_thumbnail = album_id in album_created_now needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__": if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id) # Actions diff the current album state to decide what to
# add — must observe fresh data, not a cached view.
album = await self._client.get_album(album_id, use_cache=False)
if album is None and create_if_missing and create_album_name: if album is None and create_if_missing and create_album_name:
if not dry_run: if not dry_run:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
@@ -193,6 +193,27 @@ def get_asset_video_url(
return None return None
def build_asset_media_urls(
external_url: str, asset_id: str, asset_type: str,
) -> tuple[str, str]:
"""Return ``(preview_url, full_url)`` for an Immich asset.
Single source of truth for the photo-vs-video endpoint rule. Used by
``asset_to_media`` (notification path) and the bot command handlers
(command path) so both always pick the transcoded ``/video/playback``
for videos and the preview-sized thumbnail for photos if they
diverge, Telegram ends up delivering a still JPEG for videos in a
media group.
"""
is_video = asset_type == ASSET_TYPE_VIDEO
if is_video:
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
else:
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset_id}/original"
return preview_url, full_url
def build_asset_detail( def build_asset_detail(
asset: ImmichAssetInfo, asset: ImmichAssetInfo,
external_url: str, external_url: str,
@@ -246,12 +267,7 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
# preview_url is what the notification dispatcher feeds to Telegram as the # preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4), # actual media bytes — for videos it must be the transcoded playback (mp4),
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4. # not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO: preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
return MediaAsset( return MediaAsset(
id=asset.id, id=asset.id,
@@ -317,8 +333,11 @@ def collect_scheduled_assets(
memory_date = now.isoformat() if is_memory else None memory_date = now.isoformat() if is_memory else None
all_eligible: list[ImmichAssetInfo] = [] all_eligible: list[ImmichAssetInfo] = []
# Track which album each asset belongs to for public URL construction # Track which album each asset belongs to. Public URL is used to construct
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url) # a per-asset share link; name/internal-url are surfaced to templates so
# combined-mode sends can attribute each row to its source album.
asset_album_map: dict[str, tuple[str, str, str, str]] = {}
# asset_id → (album_id, album_public_url, album_name, album_internal_url)
collections_extra: list[dict[str, Any]] = [] collections_extra: list[dict[str, Any]] = []
# limit=0 is the periodic-summary test path — the caller only needs # limit=0 is the periodic-summary test path — the caller only needs
@@ -330,10 +349,11 @@ def collect_scheduled_assets(
for album_id, album in albums.items(): for album_id, album in albums.items():
links = shared_links.get(album_id, []) links = shared_links.get(album_id, [])
album_public_url = get_public_url(external_url, links) or "" album_public_url = get_public_url(external_url, links) or ""
album_internal_url = f"{external_url}/albums/{album_id}"
collections_extra.append({ collections_extra.append({
"name": album.name, "name": album.name,
"url": album_public_url or f"{external_url}/albums/{album_id}", "url": album_public_url or album_internal_url,
"public_url": album_public_url, "public_url": album_public_url,
"asset_count": album.asset_count, "asset_count": album.asset_count,
"shared": album.shared, "shared": album.shared,
@@ -354,7 +374,9 @@ def collect_scheduled_assets(
) )
for asset in filtered: for asset in filtered:
if asset.id not in asset_album_map: if asset.id not in asset_album_map:
asset_album_map[asset.id] = (album_id, album_public_url) asset_album_map[asset.id] = (
album_id, album_public_url, album.name, album_internal_url,
)
all_eligible.append(asset) all_eligible.append(asset)
if stats_only: if stats_only:
@@ -367,15 +389,25 @@ def collect_scheduled_assets(
random.shuffle(all_eligible) random.shuffle(all_eligible)
selected = all_eligible selected = all_eligible
# Convert to MediaAsset with public URLs # Convert to MediaAsset with public URLs. Per-asset album_name/album_url
# let combined-mode templates attribute each row to its source album —
# critical when a tracker spans multiple albums, where the event-level
# ``album_name`` (first album only) would be misleading.
result: list[MediaAsset] = [] result: list[MediaAsset] = []
for asset in selected: for asset in selected:
media = asset_to_media(asset, external_url) media = asset_to_media(asset, external_url)
_, album_pub_url = asset_album_map.get(asset.id, ("", "")) mapped = asset_album_map.get(asset.id)
if mapped:
_, album_pub_url, album_name, album_internal_url = mapped
else:
album_pub_url = album_name = album_internal_url = ""
if album_pub_url: if album_pub_url:
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}" media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
else: else:
media.extra.setdefault("public_url", "") media.extra.setdefault("public_url", "")
media.extra["album_name"] = album_name
media.extra["album_url"] = album_pub_url or album_internal_url
media.extra["album_public_url"] = album_pub_url
result.append(media) result.append(media)
return result, collections_extra return result, collections_extra
@@ -13,6 +13,18 @@ from .models import ImmichAlbumData, ImmichAssetInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Guard against runaway payloads when a bulk import lands in one poll tick.
# Templates iterate every entry in ``added_assets`` / ``removed_asset_ids``
# in Jinja for-loops (see defaults/*/assets_added.jinja2), and Telegram's
# media group has a hard cap of its own — sending 200k entries would both
# crash rendering and produce a message that no transport can deliver.
#
# ``added_count`` / ``removed_count`` on the event always carry the true
# totals so templates can show an accurate "N added" number even when the
# per-asset list is truncated.
_MAX_ASSETS_PER_EVENT = 50
_MAX_REMOVALS_PER_EVENT = 200
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict: def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
"""Build the common extra dict for album events.""" """Build the common extra dict for album events."""
@@ -85,7 +97,17 @@ def detect_album_changes(
# Emit one event per change type detected # Emit one event per change type detected
if added_assets: if added_assets:
media_assets = [asset_to_media(a, external_url) for a in added_assets] total_added = len(added_assets)
truncated_added = added_assets[:_MAX_ASSETS_PER_EVENT]
media_assets = [asset_to_media(a, external_url) for a in truncated_added]
event_extra = dict(extra)
if total_added > _MAX_ASSETS_PER_EVENT:
event_extra["truncated"] = True
event_extra["shown_count"] = _MAX_ASSETS_PER_EVENT
_LOGGER.info(
"Truncated assets_added event for album %s: %d%d",
new_album.id, total_added, _MAX_ASSETS_PER_EVENT,
)
events.append(ServiceEvent( events.append(ServiceEvent(
event_type=EventType.ASSETS_ADDED, event_type=EventType.ASSETS_ADDED,
provider_type=ServiceProviderType.IMMICH, provider_type=ServiceProviderType.IMMICH,
@@ -95,12 +117,22 @@ def detect_album_changes(
timestamp=now, timestamp=now,
added_assets=media_assets, added_assets=media_assets,
removed_asset_ids=[], removed_asset_ids=[],
added_count=len(added_assets), added_count=total_added,
removed_count=0, removed_count=0,
extra=dict(extra), extra=event_extra,
)) ))
if removed_ids: if removed_ids:
total_removed = len(removed_ids)
truncated_removed = list(removed_ids)[:_MAX_REMOVALS_PER_EVENT]
event_extra = dict(extra)
if total_removed > _MAX_REMOVALS_PER_EVENT:
event_extra["truncated"] = True
event_extra["shown_count"] = _MAX_REMOVALS_PER_EVENT
_LOGGER.info(
"Truncated assets_removed event for album %s: %d%d",
new_album.id, total_removed, _MAX_REMOVALS_PER_EVENT,
)
events.append(ServiceEvent( events.append(ServiceEvent(
event_type=EventType.ASSETS_REMOVED, event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH, provider_type=ServiceProviderType.IMMICH,
@@ -109,10 +141,10 @@ def detect_album_changes(
collection_name=new_album.name, collection_name=new_album.name,
timestamp=now, timestamp=now,
added_assets=[], added_assets=[],
removed_asset_ids=list(removed_ids), removed_asset_ids=truncated_removed,
added_count=0, added_count=0,
removed_count=len(removed_ids), removed_count=total_removed,
extra=dict(extra), extra=event_extra,
)) ))
if name_changed: if name_changed:
@@ -2,14 +2,17 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import logging import logging
import re import re
import time
from typing import Any from typing import Any
import aiohttp import aiohttp
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
from .models import ImmichAlbumData, SharedLinkInfo from .models import ImmichAlbumData, ImmichAlbumMeta, SharedLinkInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -18,6 +21,51 @@ _LOGGER = logging.getLogger(__name__)
MAX_SEARCH_QUERY_LEN = 256 MAX_SEARCH_QUERY_LEN = 256
MAX_SEARCH_PERSON_IDS = 50 MAX_SEARCH_PERSON_IDS = 50
# Module-level TTL caches for album bodies and shared-link listings. The
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
# large album, and bot commands like /random, /latest, /memory all refetch
# the same album in quick succession. A short TTL makes repeat runs nearly
# instant and deduplicates concurrent fetches so a burst of commands issues
# one HTTP call instead of N.
#
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
# constructed fresh per request in several places (api/providers.py,
# services/action_runner.py, command handlers), so an instance cache would
# never survive to serve a second caller. This mirrors ``_users_cache`` in
# ``provider.py``.
_ALBUM_CACHE_TTL_SECONDS = 60
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
# Guard rail against runaway memory — a 200k-asset album response can be
# ~150 MB, so even modest caps bound the worst case.
_ALBUM_CACHE_MAX_ENTRIES = 32
_album_cache_lock = asyncio.Lock()
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
_shared_links_cache_lock = asyncio.Lock()
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
# every call was already paying for the full server-wide list. Caching the
# bucketed result once per server turns N per-album calls into one fetch.
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
def _server_digest(url: str, api_key: str) -> str:
"""Hashed key that avoids putting raw api_key into cache dict keys."""
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
def invalidate_album_cache() -> None:
"""Drop every cached album body. Call after mutations that invalidate
the cached view (e.g. integration tests, manual /refresh commands)."""
_album_cache.clear()
def invalidate_shared_links_cache() -> None:
"""Drop every cached shared-link listing."""
_shared_links_cache.clear()
# User-facing error bodies — Immich responses may leak internal paths, # User-facing error bodies — Immich responses may leak internal paths,
# hostnames, or headers injected by intermediary proxies. These helpers keep # hostnames, or headers injected by intermediary proxies. These helpers keep
# only a short, scrubbed summary; full bodies are logged server-side only. # only a short, scrubbed summary; full bodies are logged server-side only.
@@ -184,28 +232,100 @@ class ImmichClient:
return {} return {}
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]: async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
links: list[SharedLinkInfo] = [] bucketed = await self._get_shared_links_bucketed()
return list(bucketed.get(album_id, []))
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
the module-level TTL cache first. Underlying Immich endpoint has no
per-album filter, so one server-wide fetch serves every caller until
the TTL elapses.
"""
digest = _server_digest(self._url, self._api_key)
now = time.monotonic()
entry = _shared_links_cache.get(digest)
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
return entry[1]
async with _shared_links_cache_lock:
# Re-check under the lock — another coroutine may have refreshed
# while we waited.
entry = _shared_links_cache.get(digest)
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
return entry[1]
fresh = await self.get_all_shared_links_by_album()
_shared_links_cache[digest] = (time.monotonic(), fresh)
return fresh
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
"""Fetch every shared link on the server, bucketed by album id.
Immich's ``/api/shared-links`` endpoint is server-wide — there's no
per-album filter server-side so every call that wanted the links
for a single album was already paying the cost of the full listing
and then discarding most of the response. Callers that need links
for multiple albums in one tick should use this method and index
into the returned dict instead of hitting ``get_shared_links`` in
a loop.
Returns an empty dict on any error (matches the silent-failure
contract of ``get_shared_links`` so callers don't need to branch
on transient outages).
"""
result: dict[str, list[SharedLinkInfo]] = {}
try: try:
async with self._session.get( async with self._session.get(
f"{self._url}/api/shared-links", f"{self._url}/api/shared-links",
headers=self._headers, headers=self._headers,
) as response: ) as response:
if response.status == 200: if response.status != 200:
data = await response.json() _LOGGER.warning(
for link in data: "get_all_shared_links non-200: HTTP %s", response.status
album = link.get("album") )
key = link.get("key") return result
if album and key and album.get("id") == album_id: data = await response.json()
links.append(SharedLinkInfo.from_api_response(link)) for link in data:
album = link.get("album")
key = link.get("key")
if not (album and key):
continue
aid = album.get("id")
if not aid:
continue
result.setdefault(aid, []).append(
SharedLinkInfo.from_api_response(link)
)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch shared links: %s", err) _LOGGER.warning("Failed to fetch all shared links: %s", err)
return links return result
async def get_album( async def get_album(
self, self,
album_id: str, album_id: str,
users_cache: dict[str, str] | None = None, users_cache: dict[str, str] | None = None,
*,
use_cache: bool = True,
) -> ImmichAlbumData | None: ) -> ImmichAlbumData | None:
"""Fetch an album by id, optionally serving from the module-level
TTL cache. Pass ``use_cache=False`` from paths that must observe the
current server state (e.g. the notification poll loop's full-fetch
path, where a stale cached entry would delay asset-removal events).
Non-cached fetches still populate the cache for subsequent readers.
"""
cache_key = (_server_digest(self._url, self._api_key), album_id)
if use_cache:
entry = _album_cache.get(cache_key)
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
# Rehydrate per-call so ``users_cache`` enrichment is applied
# with the caller's dict, not whichever one was live when the
# cache was populated.
return ImmichAlbumData.from_api_response(entry[1], users_cache)
# Deliberately fetch without holding a lock so concurrent calls for
# *different* album_ids (the common case from asyncio.gather in
# fetch_albums_with_links) stay parallel. The worst case is a small
# duplicate-fetch stampede when two requests miss the same album at
# the same instant — acceptable for our scale.
try: try:
async with self._session.get( async with self._session.get(
f"{self._url}/api/albums/{album_id}", f"{self._url}/api/albums/{album_id}",
@@ -218,10 +338,132 @@ class ImmichClient:
f"Error fetching album {album_id}: HTTP {response.status}" f"Error fetching album {album_id}: HTTP {response.status}"
) )
data = await response.json() data = await response.json()
return ImmichAlbumData.from_api_response(data, users_cache)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error communicating with Immich: {err}") from err raise ImmichApiError(f"Error communicating with Immich: {err}") from err
async with _album_cache_lock:
# Evict the oldest entry if we're at the cap — simple FIFO is fine
# for our access pattern (commands touch a small working set).
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
_album_cache.pop(oldest, None)
_album_cache[cache_key] = (time.monotonic(), data)
return ImmichAlbumData.from_api_response(data, users_cache)
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
"""Fetch album metadata without the assets array.
Uses Immich's ``?withoutAssets=true`` query param, which skips the
(potentially huge) ``assets`` field. A 200k-asset album response
drops from ~150 MB to a few hundred bytes, so this is cheap enough
to run on every poll as a change-detection probe.
"""
try:
async with self._session.get(
f"{self._url}/api/albums/{album_id}",
params={"withoutAssets": "true"},
headers=self._headers,
) as response:
if response.status == 404:
return None
if response.status != 200:
raise ImmichApiError(
f"Error fetching album meta {album_id}: HTTP {response.status}"
)
data = await response.json()
return ImmichAlbumMeta.from_api_response(data)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
async def search_album_assets_updated_after(
self,
album_id: str,
updated_after: str,
*,
page_size: int = 1000,
max_pages: int = 50,
) -> list[dict[str, Any]]:
"""Fetch assets in ``album_id`` whose ``updatedAt`` is after ``updated_after``.
Uses ``POST /api/search/metadata`` with ``albumIds=[album_id]`` and
``updatedAfter=<iso>``. Paginates through up to ``max_pages`` pages
the cap exists so a clock-skew or upstream bug cannot produce an
infinite loop that exhausts memory on a 200k-asset album. In practice
an active album sees a few hundred updated assets per tick and
terminates after one page.
Returns raw Immich asset dicts (same shape as ``album.assets[*]``
from ``get_album``), so callers can feed them into
``ImmichAssetInfo.from_api_response`` directly.
"""
if not updated_after:
return []
page_size = max(1, min(page_size, 1000))
results: list[dict[str, Any]] = []
for page in range(1, max_pages + 1):
payload: dict[str, Any] = {
"albumIds": [album_id],
"updatedAfter": updated_after,
"page": page,
"size": page_size,
# ``withExif`` keeps location/description parity with
# ``get_album`` so downstream ``ImmichAssetInfo.from_api_response``
# populates city/country/rating on the delta path too.
"withExif": True,
"withPeople": True,
}
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich delta search non-200: HTTP %s body=%s",
response.status, _redact_body(body_snip),
)
break
data = await response.json()
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
next_page = assets_block.get("nextPage")
elif isinstance(assets_block, list):
items = assets_block
next_page = None
else:
_LOGGER.warning(
"Immich delta search returned unexpected shape: keys=%s",
list(data.keys())[:5],
)
break
results.extend(items)
# Stop early on the last page. Immich returns nextPage as
# the next page number (string or int) or None/empty when
# exhausted. Fall back to page-fullness heuristic if the
# server omits the pagination hint.
if next_page is None or next_page == "" or next_page == 0:
break
if len(items) < page_size:
break
except aiohttp.ClientError as err:
_LOGGER.warning("Immich delta search transport error: %s", err)
break
except Exception as err: # noqa: BLE001 — resilience over correctness
_LOGGER.warning("Immich delta search parse error: %s", err)
break
else:
_LOGGER.warning(
"Immich delta search for album %s hit max_pages=%d cap",
album_id, max_pages,
)
return results
async def get_albums(self) -> list[dict[str, Any]]: async def get_albums(self) -> list[dict[str, Any]]:
try: try:
async with self._session.get( async with self._session.get(
@@ -146,6 +146,49 @@ class ImmichAssetInfo:
return bool(thumbhash) return bool(thumbhash)
@dataclass(frozen=True)
class ImmichAlbumMeta:
"""Lightweight album metadata from ``GET /api/albums/{id}?withoutAssets=true``.
Used as a cheap change-detection probe so we can skip the multi-MB
full-asset fetch when nothing interesting has changed. Large albums
(tens to hundreds of thousands of assets) would otherwise re-serialize
the entire asset list on every poll interval.
"""
id: str
name: str
asset_count: int
updated_at: str
shared: bool
thumbnail_asset_id: str | None = None
@classmethod
def from_api_response(cls, data: dict[str, Any]) -> ImmichAlbumMeta:
return cls(
id=data["id"],
name=data.get("albumName", "Unnamed"),
asset_count=int(data.get("assetCount", 0) or 0),
updated_at=data.get("updatedAt", "") or "",
shared=bool(data.get("shared", False)),
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
)
def fingerprint(self) -> dict[str, Any]:
"""Return a minimal serializable dict for persistence + equality checks.
We purposefully exclude ``id`` (known from the state row) and keep the
dict flat so JSON round-trips are cheap and stable for equality.
"""
return {
"updated_at": self.updated_at,
"asset_count": self.asset_count,
"shared": self.shared,
"name": self.name,
"thumbnail_asset_id": self.thumbnail_asset_id or "",
}
@dataclass @dataclass
class ImmichAlbumData: class ImmichAlbumData:
"""Full album data from Immich API.""" """Full album data from Immich API."""
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import logging import logging
import time
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -11,13 +14,62 @@ from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
from notify_bridge_core.templates.variables import TemplateVariableDefinition from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .change_detector import detect_album_changes from .asset_utils import asset_to_media
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
from .client import ImmichClient from .client import ImmichClient
from .models import ImmichAlbumData from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Module-level users cache shared across ImmichServiceProvider instances.
# Users change rarely (new people joining the server, display-name edits), so
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
# raw api_key out of dict keys in case of a memory dump.
_USERS_CACHE_TTL_SECONDS = 3600
_users_cache_lock = asyncio.Lock()
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _users_cache_key(url: str, api_key: str) -> str:
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
return digest[:32]
async def _get_cached_users(
client: ImmichClient, url: str, api_key: str
) -> dict[str, str]:
"""Return ``{user_id: display_name}`` for the server, reusing cache entries
whose TTL has not elapsed. Misses and stale hits fall through to a real
fetch under a single lock so concurrent polls don't stampede the server.
"""
key = _users_cache_key(url, api_key)
now = time.monotonic()
entry = _users_cache.get(key)
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
async with _users_cache_lock:
# Re-check after acquiring the lock — another coroutine may have
# refreshed the entry while we waited.
entry = _users_cache.get(key)
if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
fresh = await client.get_users()
_users_cache[key] = (time.monotonic(), fresh)
return fresh
def invalidate_users_cache() -> None:
"""Drop every cached users dict. Exposed for callers that mutate users
(e.g. provider config changes, integration tests) and need the next
``connect()`` to re-fetch.
"""
_users_cache.clear()
# Immich-specific template variables # Immich-specific template variables
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [ IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition( TemplateVariableDefinition(
@@ -135,7 +187,9 @@ class ImmichServiceProvider(ServiceProvider):
await self._client.get_server_config() await self._client.get_server_config()
if self._external_domain: if self._external_domain:
self._client.external_domain = self._external_domain self._client.external_domain = self._external_domain
self._users_cache = await self._client.get_users() self._users_cache = await _get_cached_users(
self._client, self._client.url, self._client.api_key,
)
return ok return ok
async def disconnect(self) -> None: async def disconnect(self) -> None:
@@ -150,9 +204,32 @@ class ImmichServiceProvider(ServiceProvider):
new_state = dict(tracker_state) new_state = dict(tracker_state)
external_url = self._client.external_url external_url = self._client.external_url
for album_id in collection_ids: # Tick-scoped share-link cache. Populated lazily on first enrichment;
album = await self._client.get_album(album_id, self._users_cache) # a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
if album is None: # request per tick instead of 5 (and the endpoint is server-wide — each
# call was already fetching all links and discarding most of them).
self._tick_shared_links: dict[str, list] | None = None
# Fan out the cheap meta probes in parallel. For a tracker that
# watches 20 albums on the same Immich server this turns a 20-hop
# serial wait into ~1 round-trip's worth of latency. aiohttp's
# connection pool caps concurrency per host, so this can't stampede.
meta_results = await asyncio.gather(
*(self._client.get_album_meta(aid) for aid in collection_ids),
return_exceptions=True,
)
for album_id, meta_or_exc in zip(collection_ids, meta_results):
if isinstance(meta_or_exc, BaseException):
# Transient failure on this album — preserve existing state
# and move on. Logging at warning so flaky albums surface in
# the log without flooding on hard outages.
_LOGGER.warning(
"Meta probe failed for album %s: %s", album_id, meta_or_exc,
)
continue
meta = meta_or_exc
if meta is None:
# Album deleted # Album deleted
if album_id in new_state: if album_id in new_state:
from notify_bridge_core.models.events import EventType from notify_bridge_core.models.events import EventType
@@ -168,11 +245,80 @@ class ImmichServiceProvider(ServiceProvider):
del new_state[album_id] del new_state[album_id]
continue continue
# Get previous state
prev = new_state.get(album_id) prev = new_state.get(album_id)
prev_fingerprint = prev.get("meta_fingerprint") if prev else None
has_pending = bool(prev and prev.get("pending_asset_ids"))
# 2) Fast-path: fingerprint match and no pending assets → no work.
# We still refresh the fingerprint slot (no-op if identical) and
# leave asset_ids untouched on disk.
if (
prev is not None
and prev_fingerprint == meta.fingerprint()
and not has_pending
):
continue
# 3) Decide: delta fetch (cheap, active-album case) or full
# fetch (first tick + reconciliation for removals).
old_fp = prev.get("meta_fingerprint") if prev else None
old_asset_count = (old_fp or {}).get("asset_count", 0)
old_updated_at = (old_fp or {}).get("updated_at", "")
# Gate for the delta path:
# - must be tracked already (prev exists, has asset_ids)
# - must have a prior timestamp (empty ⇒ migrated DB row)
# - asset_count must not have decreased (removals need full fetch)
can_delta = (
prev is not None
and bool(prev.get("asset_ids"))
and bool(old_updated_at)
and meta.asset_count >= old_asset_count
)
if can_delta:
delta_events = await self._poll_delta(
album_id=album_id,
prev=prev,
new_meta=meta,
old_updated_at=old_updated_at,
)
if delta_events is not None:
events.extend(delta_events["events"])
new_state[album_id] = delta_events["new_state"]
continue
# delta_events is None ⇒ delta saw more additions than the
# net count increase (mixed add+remove) ⇒ fall through to
# the full-fetch path so removals get detected.
# Full fetch: first tick, or count-decreased, or delta-unsafe.
# Bypass the module-level album cache — this path runs when we
# specifically need the current server state (e.g. to detect
# asset removals), so a stale cached entry would silently delay
# the event.
album = await self._client.get_album(
album_id, self._users_cache, use_cache=False,
)
if album is None:
# Album was deleted between meta probe and full fetch — handle
# the deletion the same way as above.
if album_id in new_state:
from notify_bridge_core.models.events import EventType
from datetime import datetime, timezone
events.append(ServiceEvent(
event_type=EventType.COLLECTION_DELETED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
timestamp=datetime.now(timezone.utc),
))
del new_state[album_id]
continue
if prev is None: if prev is None:
# First time seeing this album — store state, no event # First time seeing this album — store state, no event
new_state[album_id] = _serialize_album_state(album) new_state[album_id] = _serialize_album_state(album, meta)
continue continue
# Reconstruct previous album data for comparison # Reconstruct previous album data for comparison
@@ -184,34 +330,233 @@ class ImmichServiceProvider(ServiceProvider):
) )
if detected_events: if detected_events:
# Fetch shared links to enrich events with public_url await self._enrich_with_shared_links(album_id, detected_events)
shared_links = await self._client.get_shared_links(album_id)
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
for evt in detected_events:
if public_link:
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
events.extend(detected_events) events.extend(detected_events)
# Update state # Update state
state = _serialize_album_state(album) state = _serialize_album_state(album, meta)
state["pending_asset_ids"] = list(updated_pending) state["pending_asset_ids"] = list(updated_pending)
new_state[album_id] = state new_state[album_id] = state
return events, new_state return events, new_state
async def _poll_delta(
self,
*,
album_id: str,
prev: dict[str, Any],
new_meta: ImmichAlbumMeta,
old_updated_at: str,
) -> dict[str, Any] | None:
"""Delta-fetch path for an active album.
Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
the full asset list. Returns a dict with ``events`` and ``new_state``
on success, or ``None`` to signal the caller to retry via full fetch
(used when a mixed add+remove is detected the delta endpoint can't
tell us *what* was removed, only that additions alone don't account
for the net count change).
Trades strict detection of removals-during-mixed-changes for a
drastic reduction in bytes fetched per tick. On a 200k-asset album
where 50 were just added, we fetch ~50 asset records instead of
200 000.
"""
from datetime import datetime, timezone
from notify_bridge_core.models.events import EventType
prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
prev_pending: set[str] = set(prev.get("pending_asset_ids", []))
raw_assets = await self._client.search_album_assets_updated_after(
album_id, old_updated_at
)
# Parse everything that came back. We need unprocessed entries too
# (they feed the ``pending_asset_ids`` list used by the original
# change detector's processed-later logic).
delta_assets: list[ImmichAssetInfo] = []
for raw in raw_assets:
try:
delta_assets.append(
ImmichAssetInfo.from_api_response(raw, self._users_cache)
)
except Exception as err: # noqa: BLE001 — one bad record ≠ abort tick
_LOGGER.warning(
"Skipping malformed asset record in delta response: %s", err
)
newly_added: list[ImmichAssetInfo] = []
still_pending: set[str] = set()
for asset in delta_assets:
if asset.is_processed:
if asset.id not in prev_asset_ids:
newly_added.append(asset)
else:
still_pending.add(asset.id)
old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
net_change = new_meta.asset_count - old_asset_count
# If delta found more "added" assets than the net count change,
# a concurrent removal happened. Full fetch is the only way to
# know what was removed — bail out so the caller retries.
if net_change >= 0 and len(newly_added) > net_change:
_LOGGER.info(
"Delta for album %s found %d additions but net change is %d "
"— falling back to full fetch for removal reconciliation",
album_id, len(newly_added), net_change,
)
return None
# Mirror case: positive net change we couldn't account for with the
# delta results (possibly clock skew on ``updated_at``, or an asset
# whose timestamp is before ``old_updated_at`` yet the album's
# ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
if net_change > 0 and len(newly_added) < net_change:
_LOGGER.info(
"Delta for album %s found %d additions but net change is %d "
"— falling back to full fetch to avoid missing assets",
album_id, len(newly_added), net_change,
)
return None
events: list[ServiceEvent] = []
now = datetime.now(timezone.utc)
external_url = self._external_domain or self._client.external_url
album_url = f"{external_url}/albums/{album_id}"
# Carry album-level attributes we know from the cheap meta probe.
# Shared-link enrichment happens further down only if we emitted
# any asset events.
base_extra = {
"album_url": album_url,
"shared": new_meta.shared,
"asset_count": new_meta.asset_count,
"photo_count": 0, # unknown without per-asset scan; templates tolerate 0
"video_count": 0,
"people": [],
"owner": "",
}
# Metadata-only events (no asset fetch needed)
old_fp = prev.get("meta_fingerprint") or {}
if old_fp.get("name") and old_fp["name"] != new_meta.name:
events.append(ServiceEvent(
event_type=EventType.COLLECTION_RENAMED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_name=old_fp["name"],
new_name=new_meta.name,
extra=dict(base_extra),
))
if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
events.append(ServiceEvent(
event_type=EventType.SHARING_CHANGED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_shared=bool(old_fp["shared"]),
new_shared=bool(new_meta.shared),
extra=dict(base_extra),
))
if newly_added:
total_added = len(newly_added)
truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
media_assets = [
asset_to_media(a, self._client.external_url) for a in truncated
]
extra = dict(base_extra)
if total_added > _MAX_ASSETS_PER_EVENT:
extra["truncated"] = True
extra["shown_count"] = _MAX_ASSETS_PER_EVENT
_LOGGER.info(
"Delta-path truncated assets_added event for album %s: %d%d",
album_id, total_added, _MAX_ASSETS_PER_EVENT,
)
events.append(ServiceEvent(
event_type=EventType.ASSETS_ADDED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=media_assets,
removed_asset_ids=[],
added_count=total_added,
removed_count=0,
extra=extra,
))
if events:
await self._enrich_with_shared_links(album_id, events)
# Rebuild state. asset_ids grows by the newly-added processed set.
# pending is the union of the prior pending list (things still in
# flight) and anything the delta confirmed as not-yet-processed.
# When net_change is 0 or negative we trust the meta count over
# our bookkeeping — skip-path will fix drift on the next full fetch.
new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
# Discard any previously-pending IDs that just landed as processed.
new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}
return {
"events": events,
"new_state": {
"name": new_meta.name,
"asset_ids": list(new_asset_ids),
"shared": new_meta.shared,
"pending_asset_ids": list(new_pending),
"meta_fingerprint": new_meta.fingerprint(),
},
}
async def _enrich_with_shared_links(
self, album_id: str, events_to_enrich: list[ServiceEvent]
) -> None:
"""Attach public/protected share link URLs to events for this album.
Uses the tick-scoped bulk cache populated lazily on first call, so a
tracker with changes across N albums makes one ``/api/shared-links``
request per tick instead of N.
"""
if self._tick_shared_links is None:
self._tick_shared_links = await self._client.get_all_shared_links_by_album()
shared_links = self._tick_shared_links.get(album_id, [])
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
for evt in events_to_enrich:
if public_link:
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
def get_available_variables(self) -> list[TemplateVariableDefinition]: def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(IMMICH_VARIABLES) return list(IMMICH_VARIABLES)
@@ -262,13 +607,33 @@ class ImmichServiceProvider(ServiceProvider):
return {"ok": False, "message": "Failed to connect to Immich"} return {"ok": False, "message": "Failed to connect to Immich"}
def _serialize_album_state(album: ImmichAlbumData) -> dict[str, Any]: def _serialize_album_state(
"""Serialize album state for persistence.""" album: ImmichAlbumData,
meta: ImmichAlbumMeta | None = None,
) -> dict[str, Any]:
"""Serialize album state for persistence.
``meta`` carries the fingerprint used for cheap no-change detection on
subsequent polls. When omitted (legacy callers, tests) we synthesize a
best-effort fingerprint from the full album it will still match on the
next tick if nothing changed, which is what matters.
"""
if meta is None:
fingerprint = {
"updated_at": album.updated_at,
"asset_count": len(album.asset_ids),
"shared": album.shared,
"name": album.name,
"thumbnail_asset_id": album.thumbnail_asset_id or "",
}
else:
fingerprint = meta.fingerprint()
return { return {
"name": album.name, "name": album.name,
"asset_ids": list(album.asset_ids), "asset_ids": list(album.asset_ids),
"shared": album.shared, "shared": album.shared,
"pending_asset_ids": [], "pending_asset_ids": [],
"meta_fingerprint": fingerprint,
} }
@@ -12,6 +12,7 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULT_PORT = 3493 _DEFAULT_PORT = 3493
_READ_TIMEOUT = 10.0 _READ_TIMEOUT = 10.0
_WRITE_TIMEOUT = 10.0
_CONNECT_TIMEOUT = 5.0 _CONNECT_TIMEOUT = 5.0
# Allowed characters for NUT protocol identifiers (UPS names, variable names). # Allowed characters for NUT protocol identifiers (UPS names, variable names).
@@ -84,14 +85,26 @@ class NutClient:
await self._command(f"PASSWORD {self._password}") await self._command(f"PASSWORD {self._password}")
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Send LOGOUT and close the TCP connection.""" """Send LOGOUT and close the TCP connection.
``drain`` is bounded by ``_WRITE_TIMEOUT`` so a half-closed peer
cannot hold the disconnect indefinitely a tracker tick would
otherwise be pinned by a stuck NUT server and block the scheduler
slot (``max_instances=1``).
"""
if self._writer is not None: if self._writer is not None:
try: try:
self._writer.write(b"LOGOUT\n") self._writer.write(b"LOGOUT\n")
await self._writer.drain() await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
except OSError: except (OSError, asyncio.TimeoutError):
pass pass
self._writer.close() self._writer.close()
try:
await asyncio.wait_for(
self._writer.wait_closed(), timeout=_WRITE_TIMEOUT,
)
except (OSError, asyncio.TimeoutError):
pass
self._reader = None self._reader = None
self._writer = None self._writer = None
@@ -135,7 +148,10 @@ class NutClient:
if self._writer is None: if self._writer is None:
raise NutClientError("Not connected") raise NutClientError("Not connected")
self._writer.write(f"{cmd}\n".encode()) self._writer.write(f"{cmd}\n".encode())
await self._writer.drain() try:
await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
except asyncio.TimeoutError as exc:
raise NutClientError("Write timeout") from exc
async def _readline(self) -> str: async def _readline(self) -> str:
"""Read one line from upsd, stripping trailing newline.""" """Read one line from upsd, stripping trailing newline."""
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from notify_bridge_core.models.events import EventType, ServiceEvent from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
example="Monday", example="Monday",
provider_type=ServiceProviderType.SCHEDULER, provider_type=ServiceProviderType.SCHEDULER,
), ),
TemplateVariableDefinition(
name="timezone",
type="string",
description="IANA timezone used to compute current_date/time",
example="Europe/Warsaw",
provider_type=ServiceProviderType.SCHEDULER,
),
TemplateVariableDefinition( TemplateVariableDefinition(
name="custom_vars", name="custom_vars",
type="dict", type="dict",
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
custom_variables: dict[str, str] | None = None, custom_variables: dict[str, str] | None = None,
date_format: str = "%d.%m.%Y", date_format: str = "%d.%m.%Y",
time_format: str = "%H:%M", time_format: str = "%H:%M",
datetime_format: str = "%d.%m.%Y, %H:%M UTC", datetime_format: str = "%d.%m.%Y, %H:%M %Z",
timezone_name: str | None = None,
) -> None: ) -> None:
self._name = name self._name = name
self._tracker_name = tracker_name self._tracker_name = tracker_name
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
self._date_format = date_format self._date_format = date_format
self._time_format = time_format self._time_format = time_format
self._datetime_format = datetime_format self._datetime_format = datetime_format
# Resolve a timezone for date/time rendering. Falls back to UTC on
# invalid IANA names so a typo in app settings doesn't break polls.
tz: ZoneInfo
if timezone_name:
try:
tz = ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", timezone_name)
tz = ZoneInfo("UTC")
else:
tz = ZoneInfo("UTC")
self._tz = tz
async def connect(self) -> bool: async def connect(self) -> bool:
return True # virtual provider — always connected return True # virtual provider — always connected
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
collection_ids: list[str], collection_ids: list[str],
tracker_state: dict[str, Any], tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]: ) -> tuple[list[ServiceEvent], dict[str, Any]]:
now = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
now = now_utc.astimezone(self._tz)
# State uses {collection_id: {dict}} convention like other providers # State uses {collection_id: {dict}} convention like other providers
sched_state = tracker_state.get("scheduler", {}) sched_state = tracker_state.get("scheduler", {})
fire_count = sched_state.get("fire_count", 0) + 1 fire_count = sched_state.get("fire_count", 0) + 1
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
"current_time": now.strftime(self._time_format), "current_time": now.strftime(self._time_format),
"current_datetime": now.strftime(self._datetime_format), "current_datetime": now.strftime(self._datetime_format),
"weekday": _WEEKDAYS[now.weekday()], "weekday": _WEEKDAYS[now.weekday()],
"timezone": self._tz.key,
"custom_vars": dict(self._custom_variables), "custom_vars": dict(self._custom_variables),
} }
# Flatten custom variables at top level for easy template access # Flatten custom variables at top level for easy template access
+37 -11
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
@@ -19,34 +21,58 @@ class StorageBackend(Protocol):
async def remove(self) -> None: ... async def remove(self) -> None: ...
def _read_file(path: Path) -> str | None:
if not path.exists():
return None
return path.read_text(encoding="utf-8")
def _atomic_write(path: Path, payload: str) -> None:
"""Write atomically: tmp file + rename. Prevents half-written files on crash."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(payload, encoding="utf-8")
os.replace(tmp, path)
def _remove_file(path: Path) -> None:
if path.exists():
path.unlink()
class JsonFileBackend: class JsonFileBackend:
"""Simple JSON file storage backend.""" """Simple JSON file storage backend.
All blocking I/O is wrapped in ``asyncio.to_thread`` so callers can
``await load() / save() / remove()`` without stalling the event loop.
"""
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
self._path = path self._path = path
async def load(self) -> dict[str, Any] | None: async def load(self) -> dict[str, Any] | None:
if not self._path.exists(): try:
text = await asyncio.to_thread(_read_file, self._path)
except OSError as err:
_LOGGER.warning("Failed to load %s: %s", self._path, err)
return None
if text is None:
return None return None
try: try:
text = self._path.read_text(encoding="utf-8")
return json.loads(text) return json.loads(text)
except (json.JSONDecodeError, OSError) as err: except json.JSONDecodeError as err:
_LOGGER.warning("Failed to load %s: %s", self._path, err) _LOGGER.warning("Failed to parse %s: %s", self._path, err)
return None return None
async def save(self, data: dict[str, Any]) -> None: async def save(self, data: dict[str, Any]) -> None:
payload = json.dumps(data, default=str)
try: try:
self._path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(_atomic_write, self._path, payload)
self._path.write_text(
json.dumps(data, default=str), encoding="utf-8"
)
except OSError as err: except OSError as err:
_LOGGER.error("Failed to save %s: %s", self._path, err) _LOGGER.error("Failed to save %s: %s", self._path, err)
async def remove(self) -> None: async def remove(self) -> None:
try: try:
if self._path.exists(): await asyncio.to_thread(_remove_file, self._path)
self._path.unlink()
except OSError as err: except OSError as err:
_LOGGER.error("Failed to remove %s: %s", self._path, err) _LOGGER.error("Failed to remove %s: %s", self._path, err)
@@ -1,5 +1,5 @@
⭐ Favorites: ⭐ Favorites:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️ • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,6 +1,6 @@
📸 Latest: 📸 Latest:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,5 +1,6 @@
📅 On this day: 📅 On this day:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,6 +1,6 @@
🎲 Random: 🎲 Random:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -4,7 +4,7 @@
{%- else %}🔍 Results for "{{ query }}": {%- else %}🔍 Results for "{{ query }}":
{%- endif %} {%- endif %}
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,4 +1,6 @@
📋 Album summary ({{ albums | length }}): 📋 Album summary ({{ albums | length }}):
{%- for album in albums %} {%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets • {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
{%- endfor %} {%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -1,5 +1,5 @@
⭐ Избранное: ⭐ Избранное:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️ • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,6 +1,6 @@
📸 Последние: 📸 Последние:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,5 +1,6 @@
📅 В этот день: 📅 В этот день:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,6 +1,6 @@
🎲 Случайные: 🎲 Случайные:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -4,7 +4,7 @@
{%- else %}🔍 Результаты по "{{ query }}": {%- else %}🔍 Результаты по "{{ query }}":
{%- endif %} {%- endif %}
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %} {%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %} {%- endfor %}
@@ -1,4 +1,6 @@
📋 Сводка альбомов ({{ albums | length }}): 📋 Сводка альбомов ({{ albums | length }}):
{%- for album in albums %} {%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов • {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
{%- endfor %} {%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -224,6 +224,7 @@ def build_template_context(
ctx.setdefault("current_time", event.extra.get("current_time", "")) ctx.setdefault("current_time", event.extra.get("current_time", ""))
ctx.setdefault("current_datetime", event.extra.get("current_datetime", "")) ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
ctx.setdefault("weekday", event.extra.get("weekday", "")) ctx.setdefault("weekday", event.extra.get("weekday", ""))
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {})) ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
return ctx return ctx
@@ -1,4 +1,7 @@
📅 On this day: 📅 On this day:
{%- for asset in assets %} {%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }}) • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
{%- endfor %} {%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}

Some files were not shown because too many files have changed in this diff Show More