Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa9548d884 | |||
| 72dd611f8c | |||
| 0e675c4b38 | |||
| 4307955163 | |||
| b107b01a00 | |||
| 42af7a6551 | |||
| c43dc598a1 | |||
| 1bfec521d8 | |||
| b320090a56 | |||
| cc8d961c33 | |||
| 9eb478fdc9 | |||
| ef942b77cc | |||
| 711f218622 | |||
| 9eb76c1407 | |||
| d356e5a3ac | |||
| 9643fe519e | |||
| d662b50925 | |||
| 9733e5c122 | |||
| 46a4a6ee29 | |||
| 1895c5e2d4 | |||
| 0105d9f0ec | |||
| d3210fd5ea | |||
| d9ef3c6cc3 | |||
| 1e357244e1 | |||
| 770c198ac3 | |||
| ab621b6abc | |||
| 187b889c45 | |||
| b61394f057 | |||
| be15463fd2 |
@@ -1,8 +1,8 @@
|
|||||||
# Entity Relationships
|
# Entity Relationships
|
||||||
|
|
||||||
```
|
```text
|
||||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
|
||||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||||
TrackingConfig → provider_type, event flags, scheduling rules
|
TrackingConfig → provider_type, event flags, scheduling rules
|
||||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||||
|
|||||||
+14
-15
@@ -1,28 +1,27 @@
|
|||||||
# v0.5.0 (2026-04-24)
|
# v0.6.4 (2026-04-27)
|
||||||
|
|
||||||
A small but impactful release that finally makes the Immich scheduled / periodic / memory dispatch fire on its own. The slot was already visible in the tracker UI and the "Test" button worked — but no production scheduler was reading the config, so users only ever saw fires through manual tests. This release wires the missing cron jobs end-to-end.
|
Fixes Telegram chat actions: the indicator the user picks in the UI is now actually sent, and the phantom "typing…" bubble that lingered for ~5s after a notification arrived is gone.
|
||||||
|
|
||||||
## Features
|
## User-facing changes
|
||||||
|
|
||||||
- **Cron-fired Immich dispatch for scheduled / periodic / memory slots** — adds the missing production fan-out so `scheduled_enabled` / `scheduled_times` (and the periodic / memory counterparts) on `TrackingConfig` actually fire on their own, not only through "Test" ([309dec2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/309dec2)):
|
### Bug Fixes
|
||||||
- New `services/scheduled_dispatch.py` reuses the test-path event builders, picks the slot template per kind (`scheduled_assets` / `periodic_assets` / `memory_assets`), and writes an `EventLog` row per fire so the dashboard reflects it.
|
|
||||||
- `services/scheduler.py` gains `_load_immich_dispatch_jobs`, which builds one `CronTrigger` per `(tracker, kind, HH:MM)` from each tracker's default `TrackingConfig`, all keyed off the app-level IANA timezone. `reschedule_immich_dispatch_jobs` rebuilds the job set on any relevant CRUD or timezone change.
|
|
||||||
- Tracker / link / tracking-config CRUD endpoints now invalidate the schedule, so edits take effect immediately without a restart.
|
|
||||||
- Dispatch is skipped when scheduled / memory queries yield zero matching assets — prevents header-only "On this day:" spam when nothing qualifies.
|
|
||||||
- EN / RU default `scheduled_assets` templates updated to surface that the delivery is a scheduled random selection.
|
|
||||||
|
|
||||||
## Upgrade Notes
|
- **Telegram chat action UI choice now respected:** `chat_action` was stored in two places — the model column and the config JSON — and dispatch unconditionally overrode the config value with the column. The frontend only ever wrote the JSON path, so picking "upload_photo" / "record_voice" / etc. in the UI silently had no effect on outgoing chat actions. The column is now the single source of truth: the frontend sends `chat_action` top-level, dispatch reads from the column, and a one-time migration backfills existing config values into the column and strips the legacy key ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
|
||||||
|
- **Phantom Telegram chat-action indicator:** a long-standing race in the keepalive loop (bare `sleep(4)` + `finally cancel`) could fire one last `sendChatAction` after the response had already arrived, leaving a "typing…" / "uploading…" bubble in the chat for ~5 seconds. Replaced with a stop event + `wait_for` so callers signal stop cleanly via the new `stop_keepalive` helper ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
|
||||||
|
|
||||||
- No config changes required. Existing `scheduled_enabled` / `scheduled_times` / `periodic_*` / `memory_*` settings on tracking configs will start firing automatically on the next startup.
|
## Development / Internal
|
||||||
- If you had been relying on the "Test" button as a workaround, you can stop doing that now.
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- **Migration:** new one-shot migration moves `chat_action` from `notification_target.config` JSON into the dedicated column on existing rows, then deletes the legacy key from config. No action required — runs automatically on backend start ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
|------------------------------------------------------------------------------------------|------------------------------------------------------------------|------------------|
|
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------|------------------|
|
||||||
| [309dec2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/309dec2) | feat(immich): wire cron-fired scheduled/periodic/memory dispatch | alexei.dolgolyov |
|
| [72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611) | fix(telegram): respect chat_action UI choice, drop phantom indicator | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -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 6–10 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: **2–3 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
@@ -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>
|
||||||
Generated
+48
-6
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.4",
|
||||||
"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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
// Ambient type declarations for SvelteKit + project-level build-time globals.
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
/** App version, injected from frontend/package.json at build time. */
|
||||||
|
const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -5,6 +5,23 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Notify Bridge</title>
|
<title>Notify Bridge</title>
|
||||||
|
<script>
|
||||||
|
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var resolved =
|
||||||
|
saved === 'light' || saved === 'dark'
|
||||||
|
? saved
|
||||||
|
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,10 @@
|
|||||||
<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 { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
|
||||||
|
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
|
||||||
|
|
||||||
interface LocaleMeta {
|
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
|
||||||
code: string;
|
|
||||||
name: string; // English name
|
|
||||||
native: string; // Native script
|
|
||||||
rtl?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Locales that ship with default notification & command templates.
|
// Locales that ship with default notification & command templates.
|
||||||
const SHIPPED = new Set(['en', 'ru']);
|
const SHIPPED = new Set(['en', 'ru']);
|
||||||
@@ -76,11 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function meta(code: string): LocaleMeta {
|
function meta(code: string): LocaleMeta {
|
||||||
return CATALOG.find(l => l.code === code) ?? {
|
return getLocaleMeta(code);
|
||||||
code,
|
|
||||||
name: code.toUpperCase(),
|
|
||||||
native: code.toUpperCase(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(code: string) {
|
function remove(code: string) {
|
||||||
@@ -109,79 +67,48 @@
|
|||||||
|
|
||||||
// --- Add flow ----------------------------------------------------------
|
// --- Add flow ----------------------------------------------------------
|
||||||
|
|
||||||
let addOpen = $state(false);
|
|
||||||
let addQuery = $state('');
|
|
||||||
let addInputEl = $state<HTMLInputElement | null>(null);
|
|
||||||
let highlightIdx = $state(0);
|
|
||||||
|
|
||||||
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||||
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||||
|
|
||||||
const selectedSet = $derived(new Set(codes));
|
const selectedSet = $derived(new Set(codes));
|
||||||
|
|
||||||
const suggestions = $derived.by(() => {
|
/**
|
||||||
const q = addQuery.trim().toLowerCase();
|
* Catalog languages not yet selected, surfaced through EntitySelect.
|
||||||
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
* Native name is the label so the user sees their own script; the
|
||||||
if (!q) return available;
|
* English name + code lives in the description for searchability.
|
||||||
return available.filter(l =>
|
*/
|
||||||
l.code.includes(q)
|
const addItems = $derived<EntityItem[]>(
|
||||||
|| l.name.toLowerCase().includes(q)
|
CATALOG
|
||||||
|| l.native.toLowerCase().includes(q),
|
.filter(l => !selectedSet.has(l.code))
|
||||||
);
|
.map(l => ({
|
||||||
});
|
value: l.code,
|
||||||
|
label: l.native,
|
||||||
|
desc: `${l.name} · ${l.code.toUpperCase()}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const canAddCustom = $derived.by(() => {
|
let customCode = $state('');
|
||||||
const q = addQuery.trim().toLowerCase();
|
const customCodeValid = $derived.by(() => {
|
||||||
if (!q) return false;
|
const c = customCode.trim().toLowerCase();
|
||||||
if (!CUSTOM_RE.test(q)) return false;
|
if (!c || !CUSTOM_RE.test(c)) return false;
|
||||||
if (selectedSet.has(q)) return false;
|
if (selectedSet.has(c)) return false;
|
||||||
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
if (CATALOG.some(l => l.code === c)) return false;
|
||||||
if (CATALOG.some(l => l.code === q)) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
function openAdd() {
|
function addCode(code: string | number | null) {
|
||||||
addOpen = true;
|
if (code === null) return;
|
||||||
addQuery = '';
|
const c = String(code).trim().toLowerCase();
|
||||||
highlightIdx = 0;
|
|
||||||
requestAnimationFrame(() => addInputEl?.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAdd() {
|
|
||||||
addOpen = false;
|
|
||||||
addQuery = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCode(code: string) {
|
|
||||||
const c = code.trim().toLowerCase();
|
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
commit([...codes, c]);
|
commit([...codes, c]);
|
||||||
addQuery = '';
|
|
||||||
highlightIdx = 0;
|
|
||||||
requestAnimationFrame(() => addInputEl?.focus());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddKeydown(e: KeyboardEvent) {
|
function addCustom() {
|
||||||
if (e.key === 'Escape') { closeAdd(); return; }
|
if (!customCodeValid) return;
|
||||||
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
addCode(customCode);
|
||||||
if (e.key === 'ArrowDown') {
|
customCode = '';
|
||||||
e.preventDefault();
|
|
||||||
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (highlightIdx < suggestions.length) {
|
|
||||||
addCode(suggestions[highlightIdx].code);
|
|
||||||
} else if (canAddCustom) {
|
|
||||||
addCode(addQuery);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { addQuery; highlightIdx = 0; });
|
|
||||||
|
|
||||||
// --- Drag & drop -------------------------------------------------------
|
// --- Drag & drop -------------------------------------------------------
|
||||||
|
|
||||||
let dragCode = $state<string | null>(null);
|
let dragCode = $state<string | null>(null);
|
||||||
@@ -329,77 +256,39 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Add zone -->
|
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
|
||||||
<div class="ls-add" class:ls-add-open={addOpen}>
|
<div class="ls-add">
|
||||||
{#if !addOpen}
|
<div class="ls-add-row">
|
||||||
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
<div class="ls-add-picker">
|
||||||
<MdiIcon name="mdiPlus" size={14} />
|
<EntitySelect
|
||||||
<span>{t('locales.add')}</span>
|
items={addItems}
|
||||||
</button>
|
value={null}
|
||||||
{:else}
|
placeholder={t('locales.add')}
|
||||||
<div class="ls-add-panel">
|
size="sm"
|
||||||
<div class="ls-add-input-row">
|
onselect={addCode}
|
||||||
<MdiIcon name="mdiMagnify" size={14} />
|
/>
|
||||||
<input
|
|
||||||
bind:this={addInputEl}
|
|
||||||
bind:value={addQuery}
|
|
||||||
onkeydown={onAddKeydown}
|
|
||||||
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
|
||||||
placeholder={t('locales.searchPlaceholder')}
|
|
||||||
class="ls-add-input"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
|
||||||
<MdiIcon name="mdiClose" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ls-add-list" role="listbox">
|
|
||||||
{#each suggestions as s, i (s.code)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === highlightIdx}
|
|
||||||
class="ls-sugg"
|
|
||||||
class:ls-sugg-hl={i === highlightIdx}
|
|
||||||
onmouseenter={() => highlightIdx = i}
|
|
||||||
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
|
||||||
>
|
|
||||||
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
|
||||||
<span class="ls-sugg-name">{s.name}</span>
|
|
||||||
<span class="ls-sugg-code">{s.code}</span>
|
|
||||||
{#if SHIPPED.has(s.code)}
|
|
||||||
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
|
||||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if canAddCustom}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={highlightIdx === suggestions.length}
|
|
||||||
class="ls-sugg ls-sugg-custom"
|
|
||||||
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
|
||||||
onmouseenter={() => highlightIdx = suggestions.length}
|
|
||||||
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
|
||||||
>
|
|
||||||
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
|
||||||
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
|
||||||
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if suggestions.length === 0 && !canAddCustom}
|
|
||||||
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="ls-hint">
|
<p class="ls-hint">
|
||||||
@@ -630,125 +519,60 @@
|
|||||||
.ls-add {
|
.ls-add {
|
||||||
margin-top: 0.125rem;
|
margin-top: 0.125rem;
|
||||||
}
|
}
|
||||||
.ls-add-trigger {
|
.ls-add-row {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.ls-add-trigger:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
border-style: solid;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ls-add-panel {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--color-background);
|
|
||||||
overflow: hidden;
|
|
||||||
animation: ls-pop 0.15s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes ls-pop {
|
|
||||||
from { opacity: 0; transform: translateY(-2px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.ls-add-input-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.375rem 0.625rem;
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
color: var(--color-muted-foreground);
|
|
||||||
}
|
}
|
||||||
.ls-add-input {
|
.ls-add-picker {
|
||||||
flex: 1;
|
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;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-foreground);
|
|
||||||
padding: 0.125rem 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ls-add-list {
|
|
||||||
max-height: 14rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
.ls-sugg {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0.625rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.ls-sugg.ls-sugg-hl {
|
|
||||||
background: var(--color-muted);
|
|
||||||
}
|
|
||||||
.ls-sugg-native {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ls-sugg-name {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--color-muted-foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ls-sugg-code {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.05rem 0.375rem;
|
color: var(--color-foreground);
|
||||||
border-radius: 0.25rem;
|
padding: 0.25rem 0;
|
||||||
background: var(--color-muted);
|
}
|
||||||
|
.ls-add-custom-input::placeholder {
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
.ls-add-custom-btn {
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
|
||||||
}
|
|
||||||
.ls-sugg-shipped {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--color-primary);
|
justify-content: center;
|
||||||
opacity: 0.85;
|
width: 1.5rem;
|
||||||
}
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
.ls-sugg-custom {
|
border: none;
|
||||||
border-top: 1px dashed var(--color-border);
|
background: transparent;
|
||||||
color: var(--color-primary);
|
border-radius: 0.25rem;
|
||||||
}
|
|
||||||
.ls-sugg-custom-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ls-sugg-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-muted-foreground);
|
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 --------------------------------------------------------- */
|
/* ---- Hint --------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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} <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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } 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 {
|
let {
|
||||||
value = $bindable<string>('UTC'),
|
value = $bindable<string>('UTC'),
|
||||||
@@ -172,18 +173,12 @@
|
|||||||
|
|
||||||
$effect(() => { query; highlightIdx = 0; });
|
$effect(() => { query; highlightIdx = 0; });
|
||||||
|
|
||||||
// Close on outside click
|
/**
|
||||||
function onDocClick(e: MouseEvent) {
|
* The panel is portalled to <body> to escape Card's overflow:hidden +
|
||||||
if (!open) return;
|
* backdrop-filter (which would otherwise clip and stacking-trap the
|
||||||
const target = e.target as Node;
|
* dropdown). Outside-click is detected via the dedicated overlay div
|
||||||
if (panelEl && !panelEl.contains(target)) closePicker();
|
* rather than a document listener, so we don't need a global handler.
|
||||||
}
|
*/
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener('mousedown', onDocClick);
|
|
||||||
});
|
|
||||||
onDestroy(() => {
|
|
||||||
document.removeEventListener('mousedown', onDocClick);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tz-root">
|
<div class="tz-root">
|
||||||
@@ -217,83 +212,87 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
<div use:portal class="tz-portal-root">
|
||||||
<!-- Search -->
|
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
|
||||||
<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 -->
|
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||||
{#if !query}
|
<!-- Search -->
|
||||||
<div class="tz-quick">
|
<div class="tz-search-row">
|
||||||
<button
|
<MdiIcon name="mdiMagnify" size={14} />
|
||||||
type="button"
|
<input
|
||||||
class="tz-quick-btn"
|
bind:this={inputEl}
|
||||||
class:tz-quick-active={value === detectedTz}
|
bind:value={query}
|
||||||
onclick={() => selectTz(detectedTz)}
|
onkeydown={onKeydown}
|
||||||
>
|
placeholder={t('timezone.searchPlaceholder')}
|
||||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
class="tz-search"
|
||||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
autocomplete="off"
|
||||||
<span class="tz-quick-val">{detectedTz}</span>
|
spellcheck="false"
|
||||||
</button>
|
type="text"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<kbd class="tz-kbd">ESC</kbd>
|
||||||
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>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Grouped list -->
|
<!-- Quick picks -->
|
||||||
<div class="tz-list">
|
{#if !query}
|
||||||
{#if filtered.length === 0}
|
<div class="tz-quick">
|
||||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
<button
|
||||||
{:else}
|
type="button"
|
||||||
{#each groups as g (g.region)}
|
class="tz-quick-btn"
|
||||||
<div class="tz-group">
|
class:tz-quick-active={value === detectedTz}
|
||||||
<div class="tz-group-head">
|
onclick={() => selectTz(detectedTz)}
|
||||||
<span class="tz-group-name">{g.region}</span>
|
>
|
||||||
<span class="tz-group-count">{g.items.length}</span>
|
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||||
</div>
|
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||||
{#each g.items as tz (tz)}
|
<span class="tz-quick-val">{detectedTz}</span>
|
||||||
{@const parts = splitTz(tz)}
|
</button>
|
||||||
{@const idx = flat.indexOf(tz)}
|
<button
|
||||||
{@const hl = idx === highlightIdx}
|
type="button"
|
||||||
{@const sel = tz === value}
|
class="tz-quick-btn"
|
||||||
<button
|
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||||
type="button"
|
onclick={() => selectTz('UTC')}
|
||||||
role="option"
|
>
|
||||||
aria-selected={sel}
|
<MdiIcon name="mdiEarth" size={12} />
|
||||||
class="tz-opt"
|
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||||
class:tz-opt-hl={hl}
|
<span class="tz-quick-val">UTC+00</span>
|
||||||
class:tz-opt-sel={sel}
|
</button>
|
||||||
onmouseenter={() => (highlightIdx = idx)}
|
</div>
|
||||||
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}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -408,35 +407,66 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Panel -------------------------------------------------------- */
|
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
|
||||||
.tz-panel {
|
.tz-portal-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tz-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.375rem);
|
inset: 0;
|
||||||
left: 0;
|
pointer-events: auto;
|
||||||
right: 0;
|
background: rgba(0, 0, 0, 0.55);
|
||||||
z-index: 20;
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
background: var(--color-card, var(--color-background));
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||||
border: 1px solid var(--color-border);
|
}
|
||||||
border-radius: 0.625rem;
|
|
||||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
/* ---- 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;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 26rem;
|
|
||||||
animation: tz-pop 0.15s ease-out;
|
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 {
|
@keyframes tz-pop {
|
||||||
from { opacity: 0; transform: translateY(-3px); }
|
from { opacity: 0; transform: translate(-50%, -3px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tz-search-row {
|
.tz-search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
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;
|
||||||
}
|
}
|
||||||
.tz-search {
|
.tz-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -464,6 +494,8 @@
|
|||||||
padding: 0.5rem 0.625rem;
|
padding: 0.5rem 0.625rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.tz-quick-btn {
|
.tz-quick-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -498,8 +530,13 @@
|
|||||||
|
|
||||||
.tz-list {
|
.tz-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0;
|
/* 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;
|
scrollbar-width: thin;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.tz-empty {
|
.tz-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -523,7 +560,7 @@
|
|||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: var(--color-card, var(--color-background));
|
background: var(--tz-solid-bg);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -103,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",
|
||||||
@@ -192,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",
|
||||||
@@ -204,6 +246,9 @@
|
|||||||
"selectAlbums": "Select albums...",
|
"selectAlbums": "Select albums...",
|
||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"selectRepositories": "Select repositories...",
|
"selectRepositories": "Select repositories...",
|
||||||
|
"userAllowlist": "Only from users",
|
||||||
|
"userBlocklist": "Exclude users",
|
||||||
|
"selectUsers": "Pick users...",
|
||||||
"boards": "Boards",
|
"boards": "Boards",
|
||||||
"selectBoards": "Select boards...",
|
"selectBoards": "Select boards...",
|
||||||
"upsDevices": "UPS Devices",
|
"upsDevices": "UPS Devices",
|
||||||
@@ -250,7 +295,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",
|
||||||
@@ -262,7 +308,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",
|
||||||
@@ -296,6 +349,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",
|
||||||
@@ -364,6 +422,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",
|
||||||
@@ -381,6 +441,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",
|
||||||
@@ -433,6 +495,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",
|
||||||
@@ -455,6 +519,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",
|
||||||
@@ -550,11 +616,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",
|
||||||
@@ -596,7 +672,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": {
|
||||||
@@ -665,6 +748,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",
|
||||||
@@ -713,9 +797,12 @@
|
|||||||
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.",
|
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07: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.",
|
||||||
@@ -729,7 +816,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).",
|
||||||
@@ -738,6 +825,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",
|
||||||
@@ -754,6 +843,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",
|
||||||
@@ -773,6 +864,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",
|
||||||
@@ -785,6 +878,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",
|
||||||
@@ -807,6 +902,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",
|
||||||
@@ -848,6 +944,7 @@
|
|||||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||||
"add": "Add language",
|
"add": "Add language",
|
||||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||||
|
"customPlaceholder": "or de-CH",
|
||||||
"addCustom": "Add custom code",
|
"addCustom": "Add custom code",
|
||||||
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
@@ -1089,6 +1186,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",
|
||||||
@@ -1146,6 +1245,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",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"tagline": "Уведомления о сервисах"
|
"tagline": "Уведомления о сервисах"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"sectionOverview": "Обзор",
|
||||||
|
"sectionRouting": "Маршрутизация",
|
||||||
|
"sectionOperators": "Операторы",
|
||||||
|
"sectionSystem": "Система",
|
||||||
"dashboard": "Главная",
|
"dashboard": "Главная",
|
||||||
"providers": "Провайдеры",
|
"providers": "Провайдеры",
|
||||||
"notificationTrackers": "Трекеры увед.",
|
"notificationTrackers": "Трекеры увед.",
|
||||||
@@ -103,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": "Тип провайдера",
|
||||||
@@ -192,6 +231,9 @@
|
|||||||
"cleared": "История запросов очищена"
|
"cleared": "История запросов очищена"
|
||||||
},
|
},
|
||||||
"notificationTracker": {
|
"notificationTracker": {
|
||||||
|
"titleEmphasis": "трекеры",
|
||||||
|
"armed": "активны",
|
||||||
|
"paused": "на паузе",
|
||||||
"title": "Трекеры уведомлений",
|
"title": "Трекеры уведомлений",
|
||||||
"description": "Отслеживание изменений в альбомах",
|
"description": "Отслеживание изменений в альбомах",
|
||||||
"newTracker": "Новый трекер",
|
"newTracker": "Новый трекер",
|
||||||
@@ -204,6 +246,9 @@
|
|||||||
"selectAlbums": "Выберите альбомы...",
|
"selectAlbums": "Выберите альбомы...",
|
||||||
"repositories": "Репозитории",
|
"repositories": "Репозитории",
|
||||||
"selectRepositories": "Выберите репозитории...",
|
"selectRepositories": "Выберите репозитории...",
|
||||||
|
"userAllowlist": "Только от пользователей",
|
||||||
|
"userBlocklist": "Исключить пользователей",
|
||||||
|
"selectUsers": "Выберите пользователей...",
|
||||||
"boards": "Доски",
|
"boards": "Доски",
|
||||||
"selectBoards": "Выберите доски...",
|
"selectBoards": "Выберите доски...",
|
||||||
"upsDevices": "ИБП устройства",
|
"upsDevices": "ИБП устройства",
|
||||||
@@ -250,7 +295,8 @@
|
|||||||
"descending": "По убыванию",
|
"descending": "По убыванию",
|
||||||
"quietHoursStart": "Тихие часы начало",
|
"quietHoursStart": "Тихие часы начало",
|
||||||
"quietHoursEnd": "Тихие часы конец",
|
"quietHoursEnd": "Тихие часы конец",
|
||||||
"batchDuration": "Длительность пакета (секунды)",
|
"adaptiveMaxSkip": "Предел адаптивного опроса",
|
||||||
|
"adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)",
|
||||||
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
||||||
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
||||||
"linkedTargets": "получатели",
|
"linkedTargets": "получатели",
|
||||||
@@ -262,7 +308,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": "Истёк",
|
||||||
@@ -296,6 +349,11 @@
|
|||||||
"albumDeleted": "Альбом удалён"
|
"albumDeleted": "Альбом удалён"
|
||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
|
"titleEmphasis": "канал",
|
||||||
|
"titleEmphasisAll": "каналы",
|
||||||
|
"receiver": "получатель",
|
||||||
|
"receivers": "получателей",
|
||||||
|
"channelsCount": "каналов",
|
||||||
"title": "Получатели",
|
"title": "Получатели",
|
||||||
"description": "Адреса доставки уведомлений",
|
"description": "Адреса доставки уведомлений",
|
||||||
"descTelegram": "Чаты Telegram для доставки уведомлений",
|
"descTelegram": "Чаты Telegram для доставки уведомлений",
|
||||||
@@ -364,6 +422,8 @@
|
|||||||
"receiverDisabled": "Получатель отключён"
|
"receiverDisabled": "Получатель отключён"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
|
"titleEmphasis": "и доступ",
|
||||||
|
"countLabel": "пользователей",
|
||||||
"title": "Пользователи",
|
"title": "Пользователи",
|
||||||
"description": "Управление аккаунтами (только админ)",
|
"description": "Управление аккаунтами (только админ)",
|
||||||
"addUser": "Добавить пользователя",
|
"addUser": "Добавить пользователя",
|
||||||
@@ -381,6 +441,8 @@
|
|||||||
"noUsers": "Пользователи не найдены"
|
"noUsers": "Пользователи не найдены"
|
||||||
},
|
},
|
||||||
"telegramBot": {
|
"telegramBot": {
|
||||||
|
"titleEmphasis": "telegram",
|
||||||
|
"countLabel": "ботов",
|
||||||
"title": "Telegram боты",
|
"title": "Telegram боты",
|
||||||
"description": "Регистрация и управление Telegram ботами",
|
"description": "Регистрация и управление Telegram ботами",
|
||||||
"addBot": "Добавить бота",
|
"addBot": "Добавить бота",
|
||||||
@@ -433,6 +495,8 @@
|
|||||||
"webhookRegistered": "Вебхук зарегистрирован",
|
"webhookRegistered": "Вебхук зарегистрирован",
|
||||||
"webhookUnregistered": "Вебхук удалён",
|
"webhookUnregistered": "Вебхук удалён",
|
||||||
"updateMode": "Режим обновлений",
|
"updateMode": "Режим обновлений",
|
||||||
|
"none": "Откл.",
|
||||||
|
"noneActive": "Приём обновлений отключён",
|
||||||
"polling": "Опрос",
|
"polling": "Опрос",
|
||||||
"webhook": "Вебхук",
|
"webhook": "Вебхук",
|
||||||
"webhookStatus": "Статус вебхука",
|
"webhookStatus": "Статус вебхука",
|
||||||
@@ -455,6 +519,8 @@
|
|||||||
"webhookFailed": "Не удалось зарегистрировать webhook"
|
"webhookFailed": "Не удалось зарегистрировать webhook"
|
||||||
},
|
},
|
||||||
"trackingConfig": {
|
"trackingConfig": {
|
||||||
|
"titleEmphasis": "конфигурации",
|
||||||
|
"countLabel": "конфигураций",
|
||||||
"title": "Конфигурации отслеживания",
|
"title": "Конфигурации отслеживания",
|
||||||
"description": "Определите, на какие события и файлы реагировать",
|
"description": "Определите, на какие события и файлы реагировать",
|
||||||
"newConfig": "Новая конфигурация",
|
"newConfig": "Новая конфигурация",
|
||||||
@@ -550,11 +616,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": "Название",
|
||||||
@@ -596,7 +672,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": {
|
||||||
@@ -665,6 +748,7 @@
|
|||||||
"album_shared": "Общий альбом"
|
"album_shared": "Общий альбом"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"titleEmphasis": "параметры",
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
"description": "Глобальные настройки приложения",
|
"description": "Глобальные настройки приложения",
|
||||||
"general": "Общие",
|
"general": "Общие",
|
||||||
@@ -713,9 +797,12 @@
|
|||||||
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07: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": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||||
@@ -729,7 +816,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).",
|
||||||
@@ -738,6 +825,8 @@
|
|||||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||||
},
|
},
|
||||||
"matrixBot": {
|
"matrixBot": {
|
||||||
|
"titleEmphasis": "matrix",
|
||||||
|
"countLabel": "ботов",
|
||||||
"title": "Matrix боты",
|
"title": "Matrix боты",
|
||||||
"description": "Подключения к Matrix серверам для уведомлений в комнаты",
|
"description": "Подключения к Matrix серверам для уведомлений в комнаты",
|
||||||
"addBot": "Добавить Matrix бот",
|
"addBot": "Добавить Matrix бот",
|
||||||
@@ -754,6 +843,8 @@
|
|||||||
"operationFailed": "Операция не удалась"
|
"operationFailed": "Операция не удалась"
|
||||||
},
|
},
|
||||||
"emailBot": {
|
"emailBot": {
|
||||||
|
"titleEmphasis": "email",
|
||||||
|
"countLabel": "учётных записей",
|
||||||
"title": "Email боты",
|
"title": "Email боты",
|
||||||
"description": "SMTP отправители для уведомлений по email",
|
"description": "SMTP отправители для уведомлений по email",
|
||||||
"addBot": "Добавить Email бот",
|
"addBot": "Добавить Email бот",
|
||||||
@@ -773,6 +864,8 @@
|
|||||||
"operationFailed": "Операция не удалась"
|
"operationFailed": "Операция не удалась"
|
||||||
},
|
},
|
||||||
"cmdTemplateConfig": {
|
"cmdTemplateConfig": {
|
||||||
|
"titleEmphasis": "шаблоны",
|
||||||
|
"countLabel": "шаблонов",
|
||||||
"title": "Шаблоны команд",
|
"title": "Шаблоны команд",
|
||||||
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
||||||
"newConfig": "Новый шаблон",
|
"newConfig": "Новый шаблон",
|
||||||
@@ -785,6 +878,8 @@
|
|||||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||||
},
|
},
|
||||||
"commandConfig": {
|
"commandConfig": {
|
||||||
|
"titleEmphasis": "конфигурации",
|
||||||
|
"countLabel": "конфигураций",
|
||||||
"title": "Конфигурации команд",
|
"title": "Конфигурации команд",
|
||||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||||
"newConfig": "Новая конфигурация",
|
"newConfig": "Новая конфигурация",
|
||||||
@@ -807,6 +902,7 @@
|
|||||||
"noTemplate": "По умолчанию (встроенный)"
|
"noTemplate": "По умолчанию (встроенный)"
|
||||||
},
|
},
|
||||||
"commandTracker": {
|
"commandTracker": {
|
||||||
|
"titleEmphasis": "трекеры",
|
||||||
"title": "Трекеры команд",
|
"title": "Трекеры команд",
|
||||||
"description": "Управление трекерами команд и их слушателями",
|
"description": "Управление трекерами команд и их слушателями",
|
||||||
"newTracker": "Новый трекер",
|
"newTracker": "Новый трекер",
|
||||||
@@ -848,6 +944,7 @@
|
|||||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||||
"add": "Добавить язык",
|
"add": "Добавить язык",
|
||||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||||
|
"customPlaceholder": "или de-CH",
|
||||||
"addCustom": "Добавить свой код",
|
"addCustom": "Добавить свой код",
|
||||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||||
"primary": "Основной",
|
"primary": "Основной",
|
||||||
@@ -1089,6 +1186,8 @@
|
|||||||
"close": "закрыть"
|
"close": "закрыть"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"titleEmphasis": "автоматизации",
|
||||||
|
"countLabel": "действий",
|
||||||
"title": "Действия",
|
"title": "Действия",
|
||||||
"description": "Запланированные операции над внешними сервисами",
|
"description": "Запланированные операции над внешними сервисами",
|
||||||
"addAction": "Добавить действие",
|
"addAction": "Добавить действие",
|
||||||
@@ -1146,6 +1245,7 @@
|
|||||||
"triggerScheduled": "по расписанию"
|
"triggerScheduled": "по расписанию"
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
|
"titleEmphasis": "и восстановление",
|
||||||
"title": "Резервное копирование",
|
"title": "Резервное копирование",
|
||||||
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
|
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
|
||||||
"export": "Экспорт конфигурации",
|
"export": "Экспорт конфигурации",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
|||||||
desc: () => '',
|
desc: () => '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
userFilters: [
|
||||||
|
{
|
||||||
|
key: 'senders',
|
||||||
|
label: 'notificationTracker.userAllowlist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountCheck',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exclude_senders',
|
||||||
|
label: 'notificationTracker.userBlocklist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountOff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -103,6 +120,25 @@ export interface CollectionMeta {
|
|||||||
desc: (col: any) => string;
|
desc: (col: any) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a filter that picks user identities from the provider's known
|
||||||
|
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||||
|
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||||
|
* `tracker.filters[key]`.
|
||||||
|
*/
|
||||||
|
export interface UserFilterMeta {
|
||||||
|
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||||
|
key: string;
|
||||||
|
/** i18n key for the label rendered above the picker. */
|
||||||
|
label: string;
|
||||||
|
/** i18n key for the picker placeholder. */
|
||||||
|
placeholder: string;
|
||||||
|
/** MDI icon shown on chips and dropdown rows. */
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main descriptor ──────────────────────────────────────────────────
|
// ── Main descriptor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ProviderDescriptor {
|
export interface ProviderDescriptor {
|
||||||
@@ -136,6 +172,8 @@ export interface ProviderDescriptor {
|
|||||||
// ── Collections / Trackers ──
|
// ── Collections / Trackers ──
|
||||||
/** Null means this provider has no collections (e.g. scheduler). */
|
/** Null means this provider has no collections (e.g. scheduler). */
|
||||||
collectionMeta: CollectionMeta | null;
|
collectionMeta: CollectionMeta | null;
|
||||||
|
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||||
|
userFilters?: UserFilterMeta[];
|
||||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||||
webhookBased?: boolean;
|
webhookBased?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -106,6 +106,7 @@ export interface NotificationTarget {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
chat_action?: string | null;
|
||||||
chat_name?: string;
|
chat_name?: string;
|
||||||
receiver_count: number;
|
receiver_count: number;
|
||||||
receivers: TargetReceiver[];
|
receivers: TargetReceiver[];
|
||||||
|
|||||||
+485
-178
@@ -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';
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@
|
|||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||||
let _syncingFilter = false;
|
let _syncingFilter = false;
|
||||||
|
|
||||||
|
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||||
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||||
|
// hard reload — the most visible "jump" the user reported.
|
||||||
|
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||||
|
|
||||||
// Sync filter value → store
|
// Sync filter value → store
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
const v = providerFilterValue;
|
||||||
@@ -77,7 +83,24 @@
|
|||||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let collapsed = $state(false);
|
// Read persisted UI state synchronously so first paint already matches the
|
||||||
|
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||||
|
// to collapsed (and groups slide open) right after mount.
|
||||||
|
function readPersistedCollapsed(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
|
}
|
||||||
|
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||||
|
if (typeof localStorage === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('nav_expanded');
|
||||||
|
return saved ? JSON.parse(saved) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = $state(readPersistedCollapsed());
|
||||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||||
|
|
||||||
// Nav counts — computed reactively from caches + global provider filter
|
// Nav counts — computed reactively from caches + global provider filter
|
||||||
@@ -201,8 +224,21 @@
|
|||||||
: 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>>(readPersistedExpandedGroups());
|
||||||
|
|
||||||
function toggleGroup(key: string) {
|
function toggleGroup(key: string) {
|
||||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||||
@@ -218,13 +254,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
if (!entry) return null;
|
||||||
|
return isGroup(entry)
|
||||||
|
? { href: entry.children[0]?.href ?? '/', key: entry.key, icon: entry.icon }
|
||||||
|
: entry;
|
||||||
|
}).filter((x): x is NavItem => x !== null)
|
||||||
|
);
|
||||||
|
|
||||||
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||||
// reachable on mobile (previously it was a flat hand-picked list that
|
// reachable on mobile (previously it was a flat hand-picked list that
|
||||||
@@ -241,13 +284,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
if (typeof localStorage !== 'undefined') {
|
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
// their $state initializers above to avoid a post-mount layout snap.
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('nav_expanded');
|
|
||||||
if (saved) expandedGroups = JSON.parse(saved);
|
|
||||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
|
||||||
}
|
|
||||||
await loadUser();
|
await loadUser();
|
||||||
if (!auth.user && !isAuthPage) {
|
if (!auth.user && !isAuthPage) {
|
||||||
redirecting = true;
|
redirecting = true;
|
||||||
@@ -346,36 +384,41 @@
|
|||||||
</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">v{__APP_VERSION__}</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>
|
||||||
|
|
||||||
<!-- Global provider filter -->
|
<!-- Global provider filter — kept rendered during the initial cache
|
||||||
{#if allProviders.length >= 1}
|
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||||
|
push the nav down. Hides only once we confirm zero providers. -->
|
||||||
|
{#if showProviderFilter}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={() => {
|
||||||
@@ -384,8 +427,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 />
|
||||||
@@ -393,22 +437,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
|
||||||
@@ -419,11 +453,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>
|
||||||
@@ -438,7 +472,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>
|
||||||
@@ -457,7 +491,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]}
|
||||||
@@ -470,61 +504,54 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -534,18 +561,18 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -553,7 +580,7 @@
|
|||||||
{#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={closeMobileMore} role="presentation"></div>
|
onclick={closeMobileMore} role="presentation"></div>
|
||||||
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); 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: calc(70vh - env(safe-area-inset-bottom, 0px)); 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);">
|
||||||
@@ -566,7 +593,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||||
style="color: var(--color-muted-foreground);">
|
style="color: var(--color-muted-foreground);">
|
||||||
<MdiIcon name={entry.icon} size={13} />
|
<NavIcon name={entry.icon} size={13} />
|
||||||
<span>{t(entry.key)}</span>
|
<span>{t(entry.key)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
@@ -575,7 +602,7 @@
|
|||||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
|
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'};"
|
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||||
>
|
>
|
||||||
<MdiIcon name={child.icon} size={20} />
|
<NavIcon name={child.icon} size={20} />
|
||||||
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||||
{#if child.countKey && navCounts[child.countKey]}
|
{#if child.countKey && navCounts[child.countKey]}
|
||||||
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||||
@@ -589,7 +616,7 @@
|
|||||||
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
|
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'};"
|
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||||
>
|
>
|
||||||
<MdiIcon name={entry.icon} size={18} />
|
<NavIcon name={entry.icon} size={18} />
|
||||||
<span class="text-sm flex-1">{t(entry.key)}</span>
|
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||||
{#if entry.countKey && navCounts[entry.countKey]}
|
{#if entry.countKey && navCounts[entry.countKey]}
|
||||||
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||||
@@ -601,7 +628,7 @@
|
|||||||
<button onclick={() => { closeMobileMore(); logout(); }}
|
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||||
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||||
style="color: var(--color-muted-foreground);">
|
style="color: var(--color-muted-foreground);">
|
||||||
<MdiIcon name="mdiLogout" size={18} />
|
<NavIcon name="mdiLogout" size={18} />
|
||||||
<span class="text-sm">{t('nav.logout')}</span>
|
<span class="text-sm">{t('nav.logout')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -610,10 +637,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-auto 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));">
|
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}
|
||||||
@@ -663,44 +710,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) */
|
||||||
@@ -709,88 +815,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>
|
||||||
|
|||||||
+1273
-191
File diff suppressed because it is too large
Load Diff
@@ -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)]" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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([
|
||||||
@@ -102,6 +110,27 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command-template config when the provider type changes.
|
||||||
|
// The previously-selected id may belong to a different provider type and
|
||||||
|
// would no longer appear in the filtered EntitySelect, leaving it empty.
|
||||||
|
let _prevProviderType = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
|
||||||
|
_prevProviderType = form.provider_type;
|
||||||
|
if (editing === null) {
|
||||||
|
const currentTpl = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.id === form.command_template_config_id,
|
||||||
|
);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
|
||||||
|
const first = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.provider_type === form.provider_type,
|
||||||
|
);
|
||||||
|
form.command_template_config_id = first?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editConfig(cfg: CommandConfig) {
|
function editConfig(cfg: CommandConfig) {
|
||||||
form = {
|
form = {
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
@@ -161,7 +190,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 +220,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 +245,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());
|
||||||
@@ -135,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([
|
||||||
@@ -202,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();
|
||||||
@@ -225,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();
|
||||||
@@ -253,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)) {
|
||||||
@@ -267,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();
|
||||||
@@ -298,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}
|
||||||
@@ -328,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}
|
||||||
@@ -342,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 -->
|
||||||
@@ -381,6 +479,11 @@
|
|||||||
<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]}
|
||||||
@@ -399,7 +502,7 @@
|
|||||||
|
|
||||||
{#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}
|
||||||
@@ -472,6 +575,14 @@
|
|||||||
<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 -->
|
||||||
|
|||||||
@@ -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([
|
||||||
@@ -95,6 +113,26 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command config when the provider changes. The previously
|
||||||
|
// selected id may belong to a different provider type and would no longer
|
||||||
|
// appear in the filtered EntitySelect, leaving the selector empty.
|
||||||
|
let _prevProviderId = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
|
_prevProviderId = form.provider_id;
|
||||||
|
if (editing === null) {
|
||||||
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
|
if (ptype) {
|
||||||
|
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
|
||||||
|
if (!currentCfg || currentCfg.provider_type !== ptype) {
|
||||||
|
const first = commandConfigs.find(c => c.provider_type === ptype);
|
||||||
|
form.command_config_id = first?.id ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editTracker(trk: any) {
|
function editTracker(trk: any) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name,
|
name: trk.name,
|
||||||
@@ -226,7 +264,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 +294,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||||
let templateConfigs = $derived(templateConfigsCache.items);
|
let templateConfigs = $derived(templateConfigsCache.items);
|
||||||
let collections = $state<Record<string, any>[]>([]);
|
let collections = $state<Record<string, any>[]>([]);
|
||||||
|
let users = $state<{ id: string; name: string }[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let collectionFilter = $state('');
|
let collectionFilter = $state('');
|
||||||
@@ -62,7 +64,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 +87,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 +112,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 = '';
|
||||||
@@ -131,22 +168,38 @@
|
|||||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!form.provider_id) { users = []; return; }
|
||||||
|
// Skip the fetch when the descriptor has no user filters — saves a
|
||||||
|
// pointless round-trip for providers like Immich/Scheduler.
|
||||||
|
const desc = getDescriptor(selectedProviderType);
|
||||||
|
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||||
|
try { users = await api(`/providers/${form.provider_id}/users`); }
|
||||||
|
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
||||||
|
}
|
||||||
|
|
||||||
let _prevProviderId = $state(0);
|
let _prevProviderId = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
_prevProviderId = form.provider_id;
|
_prevProviderId = form.provider_id;
|
||||||
loadCollections();
|
loadCollections();
|
||||||
// Auto-select first available tracking/template config for this provider when creating
|
loadUsers();
|
||||||
|
// Re-pick tracking/template configs for the new provider type. The
|
||||||
|
// previously-selected ids may belong to a different provider type
|
||||||
|
// and therefore no longer appear in the filtered EntitySelect list,
|
||||||
|
// which would render the selector as empty.
|
||||||
if (editing === null) {
|
if (editing === null) {
|
||||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
if (ptype) {
|
if (ptype) {
|
||||||
if (!form.default_tracking_config_id) {
|
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
||||||
|
if (!currentTc || currentTc.provider_type !== ptype) {
|
||||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_tracking_config_id = first.id;
|
form.default_tracking_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
if (!form.default_template_config_id) {
|
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
||||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_template_config_id = first.id;
|
form.default_template_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,21 +210,24 @@
|
|||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first provider if any
|
// Auto-select first provider if any
|
||||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edit(trk: Tracker) {
|
async function edit(trk: Tracker) {
|
||||||
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 || {},
|
||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
editing = trk.id; showForm = true;
|
editing = trk.id; showForm = true;
|
||||||
if (form.provider_id) await loadCollections();
|
if (form.provider_id) {
|
||||||
|
await Promise.all([loadCollections(), loadUsers()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
@@ -207,6 +263,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 +454,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>
|
||||||
@@ -409,6 +479,7 @@
|
|||||||
bind:form
|
bind:form
|
||||||
{providerItems}
|
{providerItems}
|
||||||
{collections}
|
{collections}
|
||||||
|
{users}
|
||||||
bind:collectionFilter
|
bind:collectionFilter
|
||||||
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||||
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||||
@@ -448,6 +519,7 @@
|
|||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each notificationTrackers as tracker (tracker.id)}
|
{#each notificationTrackers as tracker (tracker.id)}
|
||||||
|
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||||
<Card hover entityId={tracker.id}>
|
<Card hover entityId={tracker.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -460,7 +532,9 @@
|
|||||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||||
|
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||||
|
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||||
@@ -516,6 +590,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,13 +16,14 @@
|
|||||||
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>;
|
||||||
};
|
};
|
||||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||||
collections: any[];
|
collections: any[];
|
||||||
|
users?: { id: string; name: string }[];
|
||||||
collectionFilter?: string;
|
collectionFilter?: string;
|
||||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
form = $bindable(),
|
form = $bindable(),
|
||||||
providerItems,
|
providerItems,
|
||||||
collections,
|
collections,
|
||||||
|
users = [],
|
||||||
collectionFilter = $bindable(),
|
collectionFilter = $bindable(),
|
||||||
trackingConfigItems = [],
|
trackingConfigItems = [],
|
||||||
templateConfigItems = [],
|
templateConfigItems = [],
|
||||||
@@ -96,12 +99,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,
|
||||||
@@ -115,6 +118,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||||
|
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||||
|
{#each descriptor.userFilters as uf (uf.key)}
|
||||||
|
<div>
|
||||||
|
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||||
|
<MultiEntitySelect
|
||||||
|
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||||
|
values={form.filters[uf.key] || []}
|
||||||
|
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||||
|
placeholder={t(uf.placeholder)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isScheduler}
|
{#if isScheduler}
|
||||||
<!-- Schedule type -->
|
<!-- Schedule type -->
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
@@ -167,19 +185,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 +217,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -93,7 +93,12 @@
|
|||||||
}
|
}
|
||||||
</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 />
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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([
|
||||||
@@ -214,7 +229,7 @@
|
|||||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
|
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
|
||||||
// discord/slack
|
// discord/slack
|
||||||
username: c.username || '',
|
username: c.username || '',
|
||||||
// ntfy
|
// ntfy
|
||||||
@@ -253,7 +268,7 @@
|
|||||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
|
ai_captions: form.ai_captions,
|
||||||
};
|
};
|
||||||
} else if (formType === 'webhook') {
|
} else if (formType === 'webhook') {
|
||||||
config = { ai_captions: form.ai_captions };
|
config = { ai_captions: form.ai_captions };
|
||||||
@@ -269,10 +284,12 @@
|
|||||||
config = { child_target_ids: form.child_target_ids };
|
config = { child_target_ids: form.child_target_ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
|
||||||
|
if (formType === 'telegram') body.chat_action = form.chat_action || null;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
} else {
|
} else {
|
||||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
|
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
|
||||||
}
|
}
|
||||||
showForm = false;
|
showForm = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
@@ -418,11 +435,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}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,9 +1,18 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.5.0"
|
version = "0.6.4"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class NotificationDispatcher:
|
|||||||
if self._shared_session is not None and not self._shared_session.closed:
|
if self._shared_session is not None and not self._shared_session.closed:
|
||||||
yield self._shared_session
|
yield self._shared_session
|
||||||
return
|
return
|
||||||
async with self._session_ctx() as session:
|
async with _new_session() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
|
|||||||
@@ -6,12 +6,46 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import FormData
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
# Telegram 429 / flood-control retry settings. Telegram returns
|
||||||
|
# ``parameters.retry_after`` for rate limits; we honor it up to a cap so a
|
||||||
|
# pathological value can't park the dispatcher for minutes.
|
||||||
|
_TG_429_MAX_ATTEMPTS = 4
|
||||||
|
_TG_429_MAX_WAIT_S = 60
|
||||||
|
_RETRY_AFTER_RE = re.compile(r"retry after (\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_retry_after(result: dict[str, Any]) -> int | None:
|
||||||
|
"""Return the retry_after seconds from a Telegram error response.
|
||||||
|
|
||||||
|
Prefers the structured ``parameters.retry_after`` field; falls back to
|
||||||
|
parsing the human-readable description (``"Too Many Requests: retry
|
||||||
|
after N"``) which Telegram has been known to return without the
|
||||||
|
structured field on some endpoints.
|
||||||
|
"""
|
||||||
|
params = result.get("parameters") or {}
|
||||||
|
ra = params.get("retry_after")
|
||||||
|
if isinstance(ra, (int, float)) and ra > 0:
|
||||||
|
return int(ra)
|
||||||
|
desc = str(result.get("description", ""))
|
||||||
|
m = _RETRY_AFTER_RE.search(desc)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return int(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rate_limited(status: int, result: dict[str, Any]) -> bool:
|
||||||
|
return status == 429 or result.get("error_code") == 429
|
||||||
|
|
||||||
from .cache import TelegramFileCache
|
from .cache import TelegramFileCache
|
||||||
from .media import (
|
from .media import (
|
||||||
TELEGRAM_API_BASE_URL,
|
TELEGRAM_API_BASE_URL,
|
||||||
@@ -193,40 +227,58 @@ class TelegramClient:
|
|||||||
thumbhash: str | None,
|
thumbhash: str | None,
|
||||||
) -> NotificationResult:
|
) -> NotificationResult:
|
||||||
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
||||||
form = FormData()
|
def _build_form() -> FormData:
|
||||||
form.add_field("chat_id", chat_id)
|
f = FormData()
|
||||||
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
f.add_field("chat_id", chat_id)
|
||||||
form.add_field("parse_mode", parse_mode)
|
f.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
||||||
if caption:
|
f.add_field("parse_mode", parse_mode)
|
||||||
form.add_field("caption", caption)
|
if caption:
|
||||||
if reply_to_message_id:
|
f.add_field("caption", caption)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
if reply_to_message_id:
|
||||||
|
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
|
return f
|
||||||
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
||||||
try:
|
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
try:
|
||||||
result = await response.json()
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
if response.status == 200 and result.get("ok"):
|
result = await response.json()
|
||||||
res = result.get("result", {})
|
if response.status == 200 and result.get("ok"):
|
||||||
file_id = kind.file_id_from_result(res)
|
res = result.get("result", {})
|
||||||
if file_id and cache and cache_key:
|
file_id = kind.file_id_from_result(res)
|
||||||
await cache.async_set(
|
if file_id and cache and cache_key:
|
||||||
cache_key, file_id, kind.cache_type,
|
await cache.async_set(
|
||||||
thumbhash=thumbhash, size=len(data),
|
cache_key, file_id, kind.cache_type,
|
||||||
|
thumbhash=thumbhash, size=len(data),
|
||||||
|
)
|
||||||
|
return {"success": True, "message_id": res.get("message_id")}
|
||||||
|
|
||||||
|
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||||
|
retry_after = _extract_retry_after(result) or 1
|
||||||
|
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Telegram %s 429 (retry_after=%ds, attempt %d/%d) bytes=%d — sleeping %ds",
|
||||||
|
kind.api_method, retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||||
|
len(data), wait_s,
|
||||||
)
|
)
|
||||||
return {"success": True, "message_id": res.get("message_id")}
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_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")}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
"Telegram %s transport error (bytes=%d): %s",
|
||||||
kind.api_method, response.status, result.get("error_code"),
|
kind.api_method, len(data), err, exc_info=True,
|
||||||
result.get("description", "Unknown"), len(data),
|
|
||||||
)
|
)
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
return {"success": False, "error": str(err)}
|
||||||
except aiohttp.ClientError as err:
|
# All attempts exhausted via 429 — should be unreachable, but keep
|
||||||
_LOGGER.error(
|
# an explicit error path so we never return None.
|
||||||
"Telegram %s transport error (bytes=%d): %s",
|
return {"success": False, "error": "Telegram rate limit: max retries exhausted"}
|
||||||
kind.api_method, len(data), err, exc_info=True,
|
|
||||||
)
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
async def send_notification(
|
async def send_notification(
|
||||||
self,
|
self,
|
||||||
@@ -299,12 +351,7 @@ class TelegramClient:
|
|||||||
send_large_photos_as_documents,
|
send_large_photos_as_documents,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if typing_task:
|
await self.stop_keepalive(typing_task)
|
||||||
typing_task.cancel()
|
|
||||||
try:
|
|
||||||
await typing_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
@@ -368,20 +415,53 @@ class TelegramClient:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def start_chat_action_keepalive(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.
|
"""Repeatedly post ``action`` every 4s until stopped.
|
||||||
|
|
||||||
Telegram chat actions expire after ~5s, so callers that want the hint
|
Telegram chat actions expire after ~5s, so callers that want the hint
|
||||||
to persist through longer work (fetching assets, multi-chunk uploads)
|
to persist through longer work (fetching assets, multi-chunk uploads)
|
||||||
need a keep-alive. Cancel the task in a ``finally`` to stop it.
|
need a keep-alive.
|
||||||
|
|
||||||
|
The returned task carries an attached ``stop_event`` (``asyncio.Event``).
|
||||||
|
Stop cleanly via :meth:`stop_keepalive` — setting the event before
|
||||||
|
cancellation prevents the loop from firing one last ``sendChatAction``
|
||||||
|
after the caller's final user-visible message, which would otherwise
|
||||||
|
leave a phantom indicator hanging for ~5s.
|
||||||
"""
|
"""
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
async def action_loop() -> None:
|
async def action_loop() -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while not stop_event.is_set():
|
||||||
await self.send_chat_action(chat_id, action)
|
await self.send_chat_action(chat_id, action)
|
||||||
await asyncio.sleep(4)
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=4)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # 4s elapsed, refresh the action
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
return asyncio.create_task(action_loop())
|
|
||||||
|
task: asyncio.Task = asyncio.create_task(action_loop())
|
||||||
|
task.stop_event = stop_event # type: ignore[attr-defined]
|
||||||
|
return task
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stop_keepalive(task: asyncio.Task | None) -> None:
|
||||||
|
"""Stop a keepalive task started by :meth:`start_chat_action_keepalive`.
|
||||||
|
|
||||||
|
Sets the attached stop event before cancelling so the loop won't
|
||||||
|
fire another ``sendChatAction`` after the caller's final message
|
||||||
|
landed at Telegram.
|
||||||
|
"""
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
stop_event = getattr(task, "stop_event", None)
|
||||||
|
if stop_event is not None:
|
||||||
|
stop_event.set()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _send_photo(
|
async def _send_photo(
|
||||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||||
@@ -526,12 +606,10 @@ class TelegramClient:
|
|||||||
all_message_ids.append(result.get("message_id"))
|
all_message_ids.append(result.get("message_id"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Multi-item: download all, build form, send media group
|
# Multi-item: download all, build form, send media group.
|
||||||
form = FormData()
|
# Attachments are recorded separately so we can rebuild FormData on
|
||||||
form.add_field("chat_id", chat_id)
|
# 429 retry — aiohttp.FormData is single-use after a request.
|
||||||
if reply_to_message_id and chunk_idx == 0:
|
attachments: list[tuple[str, bytes, str, str]] = [] # (name, data, filename, content_type)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
|
||||||
|
|
||||||
media_json = []
|
media_json = []
|
||||||
upload_idx = 0
|
upload_idx = 0
|
||||||
# Track cache info per media_json entry (in order) so we can map
|
# Track cache info per media_json entry (in order) so we can map
|
||||||
@@ -646,7 +724,7 @@ class TelegramClient:
|
|||||||
attach_name = f"file{upload_idx}"
|
attach_name = f"file{upload_idx}"
|
||||||
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||||
ext = "jpg" if media_type == "photo" else "mp4"
|
ext = "jpg" if media_type == "photo" else "mp4"
|
||||||
form.add_field(attach_name, data, filename=f"media_{idx}.{ext}", content_type=ct)
|
attachments.append((attach_name, data, f"media_{idx}.{ext}", ct))
|
||||||
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
||||||
upload_idx += 1
|
upload_idx += 1
|
||||||
# Record cache key so we can store file_id from response
|
# Record cache key so we can store file_id from response
|
||||||
@@ -674,59 +752,86 @@ class TelegramClient:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
form.add_field("media", json.dumps(media_json))
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
||||||
|
|
||||||
try:
|
def _build_form() -> FormData:
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
f = FormData()
|
||||||
result = await response.json()
|
f.add_field("chat_id", chat_id)
|
||||||
if response.status == 200 and result.get("ok"):
|
if reply_to_message_id and chunk_idx == 0:
|
||||||
result_msgs = result.get("result", [])
|
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
for name, payload, filename, ct in attachments:
|
||||||
|
f.add_field(name, payload, filename=filename, content_type=ct)
|
||||||
|
f.add_field("media", json.dumps(media_json))
|
||||||
|
return f
|
||||||
|
|
||||||
|
chunk_failed_result: dict[str, Any] | None = None
|
||||||
|
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
|
result = await response.json()
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
result_msgs = result.get("result", [])
|
||||||
|
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||||
|
|
||||||
|
# Cache file_ids from response — map by position
|
||||||
|
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
||||||
|
for i, msg in enumerate(result_msgs):
|
||||||
|
if i >= len(media_cache_info):
|
||||||
|
break
|
||||||
|
info = media_cache_info[i]
|
||||||
|
if info is None:
|
||||||
|
continue # was a cache hit, skip
|
||||||
|
ck, mt, th, sz = info
|
||||||
|
file_id = None
|
||||||
|
if msg.get("photo"):
|
||||||
|
file_id = msg["photo"][-1].get("file_id")
|
||||||
|
elif msg.get("video"):
|
||||||
|
file_id = msg["video"].get("file_id")
|
||||||
|
elif msg.get("document"):
|
||||||
|
file_id = msg["document"].get("file_id")
|
||||||
|
if file_id:
|
||||||
|
cache_entries.append((ck, file_id, mt, th, sz))
|
||||||
|
if cache_entries:
|
||||||
|
# All entries in a chunk share the same cache backend
|
||||||
|
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||||
|
if eff_cache:
|
||||||
|
await eff_cache.async_set_many(cache_entries)
|
||||||
|
break # chunk succeeded
|
||||||
|
|
||||||
|
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||||
|
retry_after = _extract_retry_after(result) or 1
|
||||||
|
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Telegram sendMediaGroup 429 (retry_after=%ds, attempt %d/%d) chunk=%d/%d items=%d — sleeping %ds",
|
||||||
|
retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||||
|
chunk_idx + 1, len(chunks), len(media_json), wait_s,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
|
||||||
# Cache file_ids from response — map by position
|
|
||||||
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
|
||||||
for i, msg in enumerate(result_msgs):
|
|
||||||
if i >= len(media_cache_info):
|
|
||||||
break
|
|
||||||
info = media_cache_info[i]
|
|
||||||
if info is None:
|
|
||||||
continue # was a cache hit, skip
|
|
||||||
ck, mt, th, sz = info
|
|
||||||
file_id = None
|
|
||||||
if msg.get("photo"):
|
|
||||||
file_id = msg["photo"][-1].get("file_id")
|
|
||||||
elif msg.get("video"):
|
|
||||||
file_id = msg["video"].get("file_id")
|
|
||||||
elif msg.get("document"):
|
|
||||||
file_id = msg["document"].get("file_id")
|
|
||||||
if file_id:
|
|
||||||
cache_entries.append((ck, file_id, mt, th, sz))
|
|
||||||
if cache_entries:
|
|
||||||
# All entries in a chunk share the same cache backend
|
|
||||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
|
||||||
if eff_cache:
|
|
||||||
await eff_cache.async_set_many(cache_entries)
|
|
||||||
else:
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
||||||
response.status, result.get("error_code"),
|
response.status, result.get("error_code"),
|
||||||
result.get("description", "Unknown"),
|
result.get("description", "Unknown"),
|
||||||
chunk_idx + 1, len(chunks), len(media_json),
|
chunk_idx + 1, len(chunks), len(media_json),
|
||||||
)
|
)
|
||||||
return {
|
chunk_failed_result = {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown"),
|
"error": result.get("description", "Unknown"),
|
||||||
"error_code": result.get("error_code"),
|
"error_code": result.get("error_code"),
|
||||||
"failed_at_chunk": chunk_idx + 1,
|
"failed_at_chunk": chunk_idx + 1,
|
||||||
}
|
}
|
||||||
except aiohttp.ClientError as err:
|
break
|
||||||
_LOGGER.error(
|
except aiohttp.ClientError as err:
|
||||||
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
_LOGGER.error(
|
||||||
chunk_idx + 1, len(chunks), len(media_json), err,
|
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||||
exc_info=True,
|
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}
|
||||||
|
|
||||||
|
if chunk_failed_result is not None:
|
||||||
|
return chunk_failed_result
|
||||||
|
|
||||||
# Distinguish "posted something" from "posted nothing" so the caller
|
# Distinguish "posted something" from "posted nothing" so the caller
|
||||||
# can surface an ERROR when a command produced a caption reply but no
|
# can surface an ERROR when a command produced a caption reply but no
|
||||||
@@ -829,12 +934,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()
|
||||||
|
|||||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
|||||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
"""List users known to the Gitea instance via /users/search.
|
||||||
|
|
||||||
|
``/users/search`` with an empty ``q`` returns all users the
|
||||||
|
authenticated token can see, paginated. We cap at ``limit`` to avoid
|
||||||
|
unbounded memory on large instances; the picker only needs enough to
|
||||||
|
cover senders that may appear in webhook payloads.
|
||||||
|
"""
|
||||||
|
users: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
per_page = min(50, limit)
|
||||||
|
while len(users) < limit:
|
||||||
|
try:
|
||||||
|
async with self._session.get(
|
||||||
|
f"{self._url}/api/v1/users/search",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": str(page), "limit": str(per_page)},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
|
||||||
|
break
|
||||||
|
body = await response.json()
|
||||||
|
items = body.get("data", []) if isinstance(body, dict) else body
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
users.extend(items)
|
||||||
|
if len(items) < per_page:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||||
|
break
|
||||||
|
return users[:limit]
|
||||||
|
|
||||||
|
|
||||||
class GiteaApiError(Exception):
|
class GiteaApiError(Exception):
|
||||||
"""Raised when a Gitea API call fails."""
|
"""Raised when a Gitea API call fails."""
|
||||||
|
|||||||
@@ -333,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
|
||||||
@@ -346,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,
|
||||||
@@ -370,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:
|
||||||
@@ -383,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
{%- 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 %}
|
{%- 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 }} файлов
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
@@ -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] }})
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||||
{%- 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
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
|
{%- if albums and albums|length > 1 -%}
|
||||||
|
🗓️ Scheduled delivery — random photos from {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||||
|
{%- else -%}
|
||||||
🗓️ Scheduled delivery — random photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
🗓️ Scheduled delivery — random photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||||
|
{%- endif %}
|
||||||
{%- 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 %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
📅 В этот день:
|
📅 В этот день:
|
||||||
{%- 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] }})
|
||||||
|
{%- 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 %}
|
{%- 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 }} файлов
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
|
{%- if albums and albums|length > 1 -%}
|
||||||
|
🗓️ Доставка по расписанию — случайные фото из {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||||
|
{%- else -%}
|
||||||
🗓️ Доставка по расписанию — случайные фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
🗓️ Доставка по расписанию — случайные фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||||
|
{%- endif %}
|
||||||
{%- 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 %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
|
{%- 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 %}
|
{%- endfor %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.5.0"
|
version = "0.6.4"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -74,6 +74,36 @@ async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTe
|
|||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_command_templates(
|
||||||
|
provider_type: str,
|
||||||
|
slot_name: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the shipped Jinja2 default command templates for a provider type.
|
||||||
|
|
||||||
|
Used by the UI's "Reset to default" actions. Filtering works the same way
|
||||||
|
as the notification-template equivalent: omit ``slot_name`` for the whole
|
||||||
|
set, omit ``locale`` for every locale.
|
||||||
|
|
||||||
|
Response shape: ``{slot_name: {locale: template_text}}``
|
||||||
|
"""
|
||||||
|
from notify_bridge_core.templates.command_defaults.loader import (
|
||||||
|
load_default_command_templates,
|
||||||
|
)
|
||||||
|
from notify_bridge_core.templates.defaults.loader import get_available_locales
|
||||||
|
locales = [locale] if locale else get_available_locales()
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for loc in locales:
|
||||||
|
defaults = load_default_command_templates(loc, provider_type)
|
||||||
|
for name, text in defaults.items():
|
||||||
|
if slot_name and name != slot_name:
|
||||||
|
continue
|
||||||
|
result.setdefault(name, {})[loc] = text
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/variables")
|
@router.get("/variables")
|
||||||
async def get_command_variables(
|
async def get_command_variables(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -84,15 +114,26 @@ async def get_command_variables(
|
|||||||
}
|
}
|
||||||
asset_fields = {
|
asset_fields = {
|
||||||
"id": "Asset ID (UUID)",
|
"id": "Asset ID (UUID)",
|
||||||
"originalFileName": "Original filename",
|
"filename": "Original filename (preferred; same as originalFileName)",
|
||||||
|
"originalFileName": "Original filename (alias of filename, kept for backward-compat with older templates)",
|
||||||
"type": "IMAGE or VIDEO",
|
"type": "IMAGE or VIDEO",
|
||||||
"createdAt": "Creation date/time (ISO 8601)",
|
"created_at": "Creation date/time (ISO 8601)",
|
||||||
|
"createdAt": "Creation date/time (alias of created_at)",
|
||||||
"year": "Year of the memory (memory command only)",
|
"year": "Year of the memory (memory command only)",
|
||||||
|
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||||
|
"city": "City name (empty if unknown)",
|
||||||
|
"country": "Country name (empty if unknown)",
|
||||||
|
"is_favorite": "Whether asset is favorited (boolean)",
|
||||||
}
|
}
|
||||||
album_fields = {
|
album_fields = {
|
||||||
"name": "Album name",
|
"name": "Album name",
|
||||||
"asset_count": "Number of assets in the album",
|
|
||||||
"id": "Album ID (UUID)",
|
"id": "Album ID (UUID)",
|
||||||
|
"public_url": "Public share link URL (empty if none)",
|
||||||
|
"asset_count": "Number of assets in the album",
|
||||||
|
"photo_count": "Number of photos in the album",
|
||||||
|
"video_count": "Number of videos in the album",
|
||||||
|
"shared": "Whether the album is shared (boolean)",
|
||||||
|
"owner": "Album owner display name",
|
||||||
}
|
}
|
||||||
command_fields = {
|
command_fields = {
|
||||||
"name": "Command name (e.g. status, albums)",
|
"name": "Command name (e.g. status, albums)",
|
||||||
@@ -492,10 +533,11 @@ async def preview_raw(
|
|||||||
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
|
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
|
||||||
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
|
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
|
||||||
],
|
],
|
||||||
# /albums, /summary
|
# /albums, /summary — provide photo/video split, sharing, owner so the
|
||||||
|
# enriched summary template previews fully.
|
||||||
"albums": [
|
"albums": [
|
||||||
{"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
{"name": "Family Photos", "asset_count": 142, "photo_count": 120, "video_count": 22, "shared": True, "owner": "Alice", "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
||||||
{"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""},
|
{"name": "Vacation 2025", "asset_count": 87, "photo_count": 80, "video_count": 7, "shared": False, "owner": "Bob", "id": "def-456", "public_url": ""},
|
||||||
],
|
],
|
||||||
# /events
|
# /events
|
||||||
"events": [
|
"events": [
|
||||||
@@ -505,9 +547,12 @@ async def preview_raw(
|
|||||||
# /people
|
# /people
|
||||||
"people": ["Alice", "Bob", "Charlie"],
|
"people": ["Alice", "Bob", "Charlie"],
|
||||||
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
|
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
|
||||||
|
# ``filename`` is the canonical key (matches notification context and
|
||||||
|
# build_asset_dict output); ``originalFileName`` is kept as an alias
|
||||||
|
# so templates still using the old key render in preview.
|
||||||
"assets": [
|
"assets": [
|
||||||
{"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
{"id": "a1", "filename": "IMG_001.jpg", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
||||||
{"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
{"id": "a2", "filename": "VID_002.mp4", "originalFileName": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
||||||
],
|
],
|
||||||
"query": "sunset",
|
"query": "sunset",
|
||||||
"command": "search",
|
"command": "search",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class NotificationTrackerCreate(BaseModel):
|
|||||||
icon: str = ""
|
icon: str = ""
|
||||||
collection_ids: list[str] = []
|
collection_ids: list[str] = []
|
||||||
scan_interval: int = 60
|
scan_interval: int = 60
|
||||||
batch_duration: int = 0
|
adaptive_max_skip: int | None = None
|
||||||
default_tracking_config_id: int | None = None
|
default_tracking_config_id: int | None = None
|
||||||
default_template_config_id: int | None = None
|
default_template_config_id: int | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -48,7 +48,11 @@ class NotificationTrackerUpdate(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
collection_ids: list[str] | None = None
|
collection_ids: list[str] | None = None
|
||||||
scan_interval: int | None = None
|
scan_interval: int | None = None
|
||||||
batch_duration: int | None = None
|
# int | None is ambiguous for partial updates — we can't distinguish
|
||||||
|
# "clear the field" from "don't touch". Callers send this via
|
||||||
|
# model_dump(exclude_unset=True), so an omitted key leaves the value
|
||||||
|
# alone and an explicit null clears it back to the adaptive-off default.
|
||||||
|
adaptive_max_skip: int | None = None
|
||||||
default_tracking_config_id: int | None = None
|
default_tracking_config_id: int | None = None
|
||||||
default_template_config_id: int | None = None
|
default_template_config_id: int | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
@@ -125,7 +129,7 @@ def _build_tracker_response(
|
|||||||
"provider_id": t.provider_id,
|
"provider_id": t.provider_id,
|
||||||
"collection_ids": t.collection_ids,
|
"collection_ids": t.collection_ids,
|
||||||
"scan_interval": t.scan_interval,
|
"scan_interval": t.scan_interval,
|
||||||
"batch_duration": t.batch_duration,
|
"adaptive_max_skip": t.adaptive_max_skip,
|
||||||
"default_tracking_config_id": t.default_tracking_config_id,
|
"default_tracking_config_id": t.default_tracking_config_id,
|
||||||
"default_template_config_id": t.default_template_config_id,
|
"default_template_config_id": t.default_template_config_id,
|
||||||
"enabled": t.enabled,
|
"enabled": t.enabled,
|
||||||
@@ -149,7 +153,10 @@ async def create_notification_tracker(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tracker)
|
await session.refresh(tracker)
|
||||||
if tracker.enabled:
|
if tracker.enabled:
|
||||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
await schedule_tracker(
|
||||||
|
tracker.id, tracker.scan_interval,
|
||||||
|
adaptive_max_skip=tracker.adaptive_max_skip,
|
||||||
|
)
|
||||||
await reschedule_immich_dispatch_jobs()
|
await reschedule_immich_dispatch_jobs()
|
||||||
return await _tracker_response(session, tracker)
|
return await _tracker_response(session, tracker)
|
||||||
|
|
||||||
@@ -178,7 +185,10 @@ async def update_notification_tracker(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tracker)
|
await session.refresh(tracker)
|
||||||
if tracker.enabled:
|
if tracker.enabled:
|
||||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
await schedule_tracker(
|
||||||
|
tracker.id, tracker.scan_interval,
|
||||||
|
adaptive_max_skip=tracker.adaptive_max_skip,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await unschedule_tracker(tracker.id)
|
await unschedule_tracker(tracker.id)
|
||||||
await reschedule_immich_dispatch_jobs()
|
await reschedule_immich_dispatch_jobs()
|
||||||
@@ -270,7 +280,7 @@ async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> di
|
|||||||
"provider_id": t.provider_id,
|
"provider_id": t.provider_id,
|
||||||
"collection_ids": t.collection_ids,
|
"collection_ids": t.collection_ids,
|
||||||
"scan_interval": t.scan_interval,
|
"scan_interval": t.scan_interval,
|
||||||
"batch_duration": t.batch_duration,
|
"adaptive_max_skip": t.adaptive_max_skip,
|
||||||
"default_tracking_config_id": t.default_tracking_config_id,
|
"default_tracking_config_id": t.default_tracking_config_id,
|
||||||
"default_template_config_id": t.default_template_config_id,
|
"default_template_config_id": t.default_template_config_id,
|
||||||
"enabled": t.enabled,
|
"enabled": t.enabled,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import aiohttp
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import ServiceProvider, User
|
from ..database.models import EventLog, ServiceProvider, User
|
||||||
from ..services import (
|
from ..services import (
|
||||||
make_immich_provider, make_gitea_provider, make_planka_provider,
|
make_immich_provider, make_gitea_provider, make_planka_provider,
|
||||||
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
||||||
@@ -398,6 +398,62 @@ async def list_collections(
|
|||||||
return await list_provider_collections(provider)
|
return await list_provider_collections(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}/users")
|
||||||
|
async def list_provider_users(
|
||||||
|
provider_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return user identities for sender allowlist/blocklist pickers.
|
||||||
|
|
||||||
|
Two sources are merged so the picker is useful both before and after the
|
||||||
|
first webhook arrives:
|
||||||
|
|
||||||
|
- **Provider API** (primary): Gitea's ``/users/search`` returns instance
|
||||||
|
users the api_token can see. Skipped when no api_token is set.
|
||||||
|
- **Past senders** (fallback): distinct ``sender`` values from
|
||||||
|
``EventLog.details`` for this provider, so pre-existing trackers stay
|
||||||
|
filterable even if the API call fails or is unconfigured.
|
||||||
|
"""
|
||||||
|
provider = await _get_user_provider(session, provider_id, user.id)
|
||||||
|
|
||||||
|
users_by_id: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 1. Try the provider API.
|
||||||
|
if provider.type == "gitea" and (provider.config or {}).get("api_token"):
|
||||||
|
from notify_bridge_core.providers.gitea.client import GiteaClient
|
||||||
|
http_session = await get_http_session()
|
||||||
|
client = GiteaClient(
|
||||||
|
http_session,
|
||||||
|
provider.config.get("url", ""),
|
||||||
|
provider.config.get("api_token", ""),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for u in await client.get_users():
|
||||||
|
login = u.get("login", "")
|
||||||
|
if isinstance(login, str) and login:
|
||||||
|
users_by_id[login] = u.get("full_name") or login
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning("Failed to fetch Gitea users via API", exc_info=True)
|
||||||
|
|
||||||
|
# 2. Merge in past senders (covers users not visible to the API token, or
|
||||||
|
# cases where the API call fails).
|
||||||
|
result = await session.exec(
|
||||||
|
select(EventLog.details).where(EventLog.provider_id == provider.id)
|
||||||
|
)
|
||||||
|
for details in result.all():
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
continue
|
||||||
|
sender = details.get("sender", "")
|
||||||
|
if isinstance(sender, str) and sender and sender not in users_by_id:
|
||||||
|
users_by_id[sender] = sender
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": login, "name": name}
|
||||||
|
for login, name in sorted(users_by_id.items(), key=lambda kv: kv[0].lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||||
async def get_album_shared_links(
|
async def get_album_shared_links(
|
||||||
provider_id: int,
|
provider_id: int,
|
||||||
@@ -426,19 +482,45 @@ async def get_album_shared_links(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSharedLinkRequest(BaseModel):
|
||||||
|
"""Options for POST /shared-links.
|
||||||
|
|
||||||
|
``replace=True`` deletes every existing link for the album before creating
|
||||||
|
the new one, which is the only way to repair an expired or password-
|
||||||
|
protected link in the Immich API (there is no in-place "reset" endpoint).
|
||||||
|
Default ``False`` preserves the original additive behaviour used by the
|
||||||
|
"auto-create missing links" flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
replace: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
||||||
async def create_album_shared_link(
|
async def create_album_shared_link(
|
||||||
provider_id: int,
|
provider_id: int,
|
||||||
album_id: str,
|
album_id: str,
|
||||||
|
body: CreateSharedLinkRequest | None = None,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Auto-create a public shared link for an album."""
|
"""Auto-create a public shared link for an album.
|
||||||
|
|
||||||
|
With ``replace=True`` existing links for the album are deleted first, so
|
||||||
|
expired/password-protected links are effectively recycled into a fresh
|
||||||
|
public one.
|
||||||
|
"""
|
||||||
provider = await _get_user_provider(session, provider_id, user.id)
|
provider = await _get_user_provider(session, provider_id, user.id)
|
||||||
|
|
||||||
if provider.type == "immich":
|
if provider.type == "immich":
|
||||||
http_session = await get_http_session()
|
http_session = await get_http_session()
|
||||||
immich = make_immich_provider(http_session, provider)
|
immich = make_immich_provider(http_session, provider)
|
||||||
|
if body and body.replace:
|
||||||
|
# Best-effort delete; if any delete fails we still try to create —
|
||||||
|
# the user will see the new link co-exist alongside the old one,
|
||||||
|
# which is better than a hard failure that leaves them stuck.
|
||||||
|
existing = await immich.client.get_shared_links(album_id)
|
||||||
|
for link in existing:
|
||||||
|
await immich.client.delete_shared_link(link.id)
|
||||||
success = await immich.client.create_shared_link(album_id)
|
success = await immich.client.create_shared_link(album_id)
|
||||||
if success:
|
if success:
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import NotificationTarget, TargetReceiver, User
|
from ..database.models import NotificationTarget, TargetReceiver, User
|
||||||
from ..services.notifier import send_to_receiver
|
from ..services.notifier import (
|
||||||
|
_get_test_message,
|
||||||
|
resolve_telegram_chat_locale,
|
||||||
|
send_to_receiver,
|
||||||
|
)
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -130,14 +134,28 @@ async def test_receiver(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test notification to a single receiver."""
|
"""Send a test notification to a single receiver.
|
||||||
|
|
||||||
|
For Telegram targets, locale resolution goes through the shared
|
||||||
|
``resolve_telegram_chat_locale`` helper so the per-chat ``language_override``
|
||||||
|
set in the bot manager is respected here too — previously this endpoint
|
||||||
|
only consulted ``receiver.locale`` and ignored chat-side overrides.
|
||||||
|
"""
|
||||||
target = await _get_user_target(session, target_id, user.id)
|
target = await _get_user_target(session, target_id, user.id)
|
||||||
receiver = await session.get(TargetReceiver, receiver_id)
|
receiver = await session.get(TargetReceiver, receiver_id)
|
||||||
if not receiver or receiver.target_id != target_id:
|
if not receiver or receiver.target_id != target_id:
|
||||||
raise HTTPException(status_code=404, detail="Receiver not found")
|
raise HTTPException(status_code=404, detail="Receiver not found")
|
||||||
|
|
||||||
from ..services.notifier import _get_test_message
|
if target.type == "telegram":
|
||||||
effective_locale = getattr(receiver, 'locale', '') or locale
|
effective_locale = await resolve_telegram_chat_locale(
|
||||||
|
session,
|
||||||
|
bot_id=target.config.get("bot_id"),
|
||||||
|
chat_id=receiver.config.get("chat_id"),
|
||||||
|
receiver=receiver,
|
||||||
|
fallback=locale,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_locale = (getattr(receiver, "locale", "") or locale)[:2].lower()
|
||||||
message = _get_test_message(effective_locale, target.type)
|
message = _get_test_message(effective_locale, target.type)
|
||||||
return await send_to_receiver(target, dict(receiver.config), message)
|
return await send_to_receiver(target, dict(receiver.config), message)
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..commands.handler import register_commands_with_telegram
|
from ..commands.handler import register_commands_with_telegram, sync_chat_command_binding
|
||||||
from ..commands.webhook import register_webhook, unregister_webhook
|
from ..commands.webhook import register_webhook, unregister_webhook
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
||||||
from ..services.notifier import _get_test_message
|
from ..services.notifier import _get_test_message, resolve_telegram_chat_locale
|
||||||
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
||||||
from .app_settings import get_setting
|
from .app_settings import get_setting
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
@@ -86,6 +86,11 @@ async def update_bot(
|
|||||||
bot.icon = body.icon
|
bot.icon = body.icon
|
||||||
# Handle mode switching
|
# Handle mode switching
|
||||||
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
||||||
|
if body.update_mode not in ("none", "polling", "webhook"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid update_mode: {body.update_mode!r}. Must be 'none', 'polling', or 'webhook'.",
|
||||||
|
)
|
||||||
if body.update_mode == "webhook":
|
if body.update_mode == "webhook":
|
||||||
# Validate and register webhook BEFORE stopping polling
|
# Validate and register webhook BEFORE stopping polling
|
||||||
base_url = await get_setting(session, "external_url")
|
base_url = await get_setting(session, "external_url")
|
||||||
@@ -108,6 +113,12 @@ async def update_bot(
|
|||||||
# Switching to polling: unregister webhook, start polling
|
# Switching to polling: unregister webhook, start polling
|
||||||
await unregister_webhook(bot.token)
|
await unregister_webhook(bot.token)
|
||||||
schedule_bot_polling(bot.id)
|
schedule_bot_polling(bot.id)
|
||||||
|
elif body.update_mode == "none":
|
||||||
|
# Disable listener: stop polling and clear any webhook so Telegram
|
||||||
|
# stops delivering updates. This makes the bot send-only, which is
|
||||||
|
# safe when another instance owns the listener.
|
||||||
|
unschedule_bot_polling(bot.id)
|
||||||
|
await unregister_webhook(bot.token)
|
||||||
bot.update_mode = body.update_mode
|
bot.update_mode = body.update_mode
|
||||||
|
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
@@ -287,10 +298,18 @@ async def test_chat(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test message to a chat via the bot."""
|
"""Send a test message to a chat via the bot.
|
||||||
|
|
||||||
|
Locale resolution is delegated to ``resolve_telegram_chat_locale`` so this
|
||||||
|
endpoint, the per-receiver fan-out, and the target receiver test all
|
||||||
|
apply the same priority order (override → language_code → fallback).
|
||||||
|
"""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
|
effective_locale = await resolve_telegram_chat_locale(
|
||||||
|
session, bot_id=bot_id, chat_id=chat_id, fallback=locale,
|
||||||
|
)
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
message = _get_test_message(locale, "telegram")
|
message = _get_test_message(effective_locale, "telegram")
|
||||||
http = await get_http_session()
|
http = await get_http_session()
|
||||||
client = TelegramClient(http, bot.token)
|
client = TelegramClient(http, bot.token)
|
||||||
return await client.send_message(chat_id, message)
|
return await client.send_message(chat_id, message)
|
||||||
@@ -316,11 +335,37 @@ async def update_chat(
|
|||||||
if not chat or chat.bot_id != bot_id:
|
if not chat or chat.bot_id != bot_id:
|
||||||
raise HTTPException(status_code=404, detail="Chat not found")
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
# Track whether anything changed that affects the chat-scoped command
|
||||||
|
# binding registered with Telegram (so the per-chat language_override
|
||||||
|
# actually takes effect on the bot's command list, not just the reply
|
||||||
|
# locale). We push it inline rather than via the debounced auto-sync
|
||||||
|
# so the user sees the change reflected on Telegram immediately —
|
||||||
|
# Telegram clients still cache the menu until the next "/" or chat
|
||||||
|
# re-open, but the source of truth is correct from the moment save
|
||||||
|
# returns.
|
||||||
|
sync_relevant_keys = {"language_override", "commands_enabled"}
|
||||||
|
needs_sync = any(
|
||||||
|
key in updates and getattr(chat, key) != value
|
||||||
|
for key, value in updates.items()
|
||||||
|
if key in sync_relevant_keys
|
||||||
|
)
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
setattr(chat, key, value)
|
setattr(chat, key, value)
|
||||||
session.add(chat)
|
session.add(chat)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(chat)
|
await session.refresh(chat)
|
||||||
|
if needs_sync:
|
||||||
|
bot = await session.get(TelegramBot, bot_id)
|
||||||
|
if bot is not None:
|
||||||
|
try:
|
||||||
|
await sync_chat_command_binding(bot, chat)
|
||||||
|
except Exception:
|
||||||
|
# Telegram-side failure shouldn't block the save — the
|
||||||
|
# debounced bot-wide sync will retry on the next change.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Inline command sync failed for bot=%d chat=%s",
|
||||||
|
bot_id, chat.chat_id, exc_info=True,
|
||||||
|
)
|
||||||
return _chat_response(chat)
|
return _chat_response(chat)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +451,7 @@ def _bot_response(b: TelegramBot) -> dict:
|
|||||||
"bot_username": b.bot_username,
|
"bot_username": b.bot_username,
|
||||||
"bot_id": b.bot_id,
|
"bot_id": b.bot_id,
|
||||||
"webhook_path_id": b.webhook_path_id,
|
"webhook_path_id": b.webhook_path_id,
|
||||||
"update_mode": b.update_mode or "polling",
|
"update_mode": b.update_mode or "none",
|
||||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||||
"created_at": b.created_at.isoformat(),
|
"created_at": b.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,37 @@ async def list_configs(
|
|||||||
return [await _response(session, c) for c in result.all()]
|
return [await _response(session, c) for c in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_slot_templates(
|
||||||
|
provider_type: str,
|
||||||
|
slot_name: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the shipped Jinja2 default templates for a provider type.
|
||||||
|
|
||||||
|
Used by the UI's "Reset to default" actions. Filtering is optional —
|
||||||
|
omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
|
||||||
|
Registered before ``/{config_id}`` so the literal path wins over the
|
||||||
|
path-parameter route in FastAPI's matcher.
|
||||||
|
|
||||||
|
Response shape: ``{slot_name: {locale: template_text}}``
|
||||||
|
"""
|
||||||
|
from notify_bridge_core.templates.defaults.loader import (
|
||||||
|
get_available_locales,
|
||||||
|
load_default_templates,
|
||||||
|
)
|
||||||
|
locales = [locale] if locale else get_available_locales()
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for loc in locales:
|
||||||
|
defaults = load_default_templates(loc, provider_type)
|
||||||
|
for name, text in defaults.items():
|
||||||
|
if slot_name and name != slot_name:
|
||||||
|
continue
|
||||||
|
result.setdefault(name, {})[loc] = text
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/variables")
|
@router.get("/variables")
|
||||||
async def get_template_variables(
|
async def get_template_variables(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -170,13 +201,20 @@ async def get_template_variables(
|
|||||||
"download_url": "Direct download URL (if shared)",
|
"download_url": "Direct download URL (if shared)",
|
||||||
"photo_url": "Preview image URL (images only, if shared)",
|
"photo_url": "Preview image URL (images only, if shared)",
|
||||||
"playback_url": "Video playback URL (videos only, if shared)",
|
"playback_url": "Video playback URL (videos only, if shared)",
|
||||||
|
# Per-asset album attribution (scheduled/memory templates in combined mode).
|
||||||
|
"album_name": "Source album name (combined-mode scheduled/memory only)",
|
||||||
|
"album_url": "Source album URL — public share link if available, else internal album URL",
|
||||||
|
"album_public_url": "Source album public share URL (empty if no public link)",
|
||||||
}
|
}
|
||||||
album_fields = {
|
album_fields = {
|
||||||
"name": "Collection/album name",
|
"name": "Collection/album name",
|
||||||
"url": "Share URL",
|
"url": "Share URL",
|
||||||
"public_url": "Public share link URL",
|
"public_url": "Public share link URL",
|
||||||
"asset_count": "Total assets in collection",
|
"asset_count": "Total assets in collection",
|
||||||
"shared": "Whether collection is shared",
|
"photo_count": "Number of photos in the album",
|
||||||
|
"video_count": "Number of videos in the album",
|
||||||
|
"shared": "Whether collection is shared (boolean)",
|
||||||
|
"owner": "Album owner display name",
|
||||||
}
|
}
|
||||||
scheduled_vars = {
|
scheduled_vars = {
|
||||||
"date": "Current date string",
|
"date": "Current date string",
|
||||||
@@ -217,12 +255,26 @@ async def get_template_variables(
|
|||||||
},
|
},
|
||||||
"scheduled_assets_message": {
|
"scheduled_assets_message": {
|
||||||
"description": "Scheduled asset delivery (daily photo picks)",
|
"description": "Scheduled asset delivery (daily photo picks)",
|
||||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
"variables": {
|
||||||
|
**scheduled_vars,
|
||||||
|
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||||
|
"album_name": "Source album name",
|
||||||
|
"public_url": "Public share link URL for the source album (empty if none)",
|
||||||
|
"asset_count": "Total assets in the source album",
|
||||||
|
"photo_count": "Photos in the source album",
|
||||||
|
"video_count": "Videos in the source album",
|
||||||
|
"owner": "Source album owner",
|
||||||
|
},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
"memory_mode_message": {
|
"memory_mode_message": {
|
||||||
"description": "\"On This Day\" memories from previous years",
|
"description": "\"On This Day\" memories from previous years",
|
||||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
"variables": {
|
||||||
|
**scheduled_vars,
|
||||||
|
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||||
|
"album_name": "Source album name (when rendered per-album)",
|
||||||
|
"public_url": "Public share link URL for the source album (empty if none)",
|
||||||
|
},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
# --- Generic Webhook slots ---
|
# --- Generic Webhook slots ---
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TrackingConfigCreate(BaseModel):
|
|||||||
notify_favorites_only: bool = False
|
notify_favorites_only: bool = False
|
||||||
include_tags: bool = True
|
include_tags: bool = True
|
||||||
include_asset_details: bool = False
|
include_asset_details: bool = False
|
||||||
max_assets_to_show: int = 5
|
max_assets_to_show: int = 10
|
||||||
assets_order_by: str = "none"
|
assets_order_by: str = "none"
|
||||||
assets_order: str = "descending"
|
assets_order: str = "descending"
|
||||||
periodic_enabled: bool = False
|
periodic_enabled: bool = False
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ..database.models import (
|
|||||||
WebhookPayloadLog,
|
WebhookPayloadLog,
|
||||||
)
|
)
|
||||||
from ..services.dispatch_helpers import (
|
from ..services.dispatch_helpers import (
|
||||||
|
apply_tracking_display_filters,
|
||||||
event_allowed_by_config,
|
event_allowed_by_config,
|
||||||
get_app_timezone,
|
get_app_timezone,
|
||||||
load_link_data,
|
load_link_data,
|
||||||
@@ -207,9 +208,13 @@ async def _dispatch_webhook_event(
|
|||||||
# Dispatch to targets
|
# Dispatch to targets
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
dispatcher = NotificationDispatcher(session=await get_http_session())
|
dispatcher = NotificationDispatcher(session=await get_http_session())
|
||||||
target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
|
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
|
||||||
if target_configs:
|
if not target_configs:
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
continue
|
||||||
|
shaped_event = apply_tracking_display_filters(event, tc)
|
||||||
|
if shaped_event is None:
|
||||||
|
continue
|
||||||
|
results = await dispatcher.dispatch(shaped_event, target_configs)
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.get("success"):
|
if r.get("success"):
|
||||||
dispatched += 1
|
dispatched += 1
|
||||||
@@ -551,21 +556,27 @@ async def generic_webhook(token: str, request: Request):
|
|||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|
||||||
|
|
||||||
def _build_target_configs(
|
def _build_target_groups(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
link_data: list[dict[str, Any]],
|
link_data: list[dict[str, Any]],
|
||||||
provider_config: dict[str, Any],
|
provider_config: dict[str, Any],
|
||||||
app_tz: str = "UTC",
|
app_tz: str = "UTC",
|
||||||
) -> list[TargetConfig]:
|
) -> list[tuple[Any, list[TargetConfig]]]:
|
||||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
|
||||||
target_configs: list[TargetConfig] = []
|
|
||||||
|
Targets sharing a TrackingConfig dispatch together so a single
|
||||||
|
``apply_tracking_display_filters`` pass can shape one event for the
|
||||||
|
whole group; targets with different TCs may see differently-shaped
|
||||||
|
events (e.g. one with favorites_only, one without).
|
||||||
|
"""
|
||||||
|
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
|
||||||
for ld in link_data:
|
for ld in link_data:
|
||||||
tc = ld["tracking_config"]
|
tc = ld["tracking_config"]
|
||||||
if tc and not event_allowed_by_config(event, tc, app_tz):
|
if tc and not event_allowed_by_config(event, tc, app_tz):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tmpl = ld["template_config"]
|
tmpl = ld["template_config"]
|
||||||
target_configs.append(TargetConfig(
|
target_cfg = TargetConfig(
|
||||||
type=ld["target_type"],
|
type=ld["target_type"],
|
||||||
config=ld["target_config"],
|
config=ld["target_config"],
|
||||||
template_slots=ld["template_slots"],
|
template_slots=ld["template_slots"],
|
||||||
@@ -575,5 +586,9 @@ def _build_target_configs(
|
|||||||
provider_internal_url=provider_config.get("url", ""),
|
provider_internal_url=provider_config.get("url", ""),
|
||||||
provider_external_url=provider_config.get("url", ""),
|
provider_external_url=provider_config.get("url", ""),
|
||||||
receivers=ld["receivers"],
|
receivers=ld["receivers"],
|
||||||
))
|
)
|
||||||
return target_configs
|
key = id(tc) if tc is not None else 0
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = (tc, [])
|
||||||
|
groups[key][1].append(target_cfg)
|
||||||
|
return list(groups.values())
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from ..database.models import (
|
|||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
TelegramBot,
|
TelegramBot,
|
||||||
|
TelegramChat,
|
||||||
)
|
)
|
||||||
from .base import CommandResponse
|
from .base import CommandResponse
|
||||||
from .parser import parse_command
|
from .parser import parse_command
|
||||||
@@ -483,8 +484,87 @@ async def send_media_group(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_locale(raw: str | None) -> str:
|
||||||
|
"""Mirror the locale normalization used by the message handler."""
|
||||||
|
locale = (raw or "")[:2].lower()
|
||||||
|
if locale not in ("en", "ru"):
|
||||||
|
locale = "en"
|
||||||
|
return locale
|
||||||
|
|
||||||
|
|
||||||
|
def _build_command_list(
|
||||||
|
enabled: list[str], templates: dict[str, dict[str, str]], locale: str,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
commands: list[dict[str, str]] = []
|
||||||
|
for cmd in enabled:
|
||||||
|
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||||
|
commands.append({"command": cmd, "description": desc})
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_chat_command_binding(bot: TelegramBot, chat: TelegramChat) -> bool:
|
||||||
|
"""Push Telegram's per-chat command binding for a single chat.
|
||||||
|
|
||||||
|
Used for immediate refresh when the user toggles a chat's
|
||||||
|
``language_override`` or ``commands_enabled`` flag — avoids the
|
||||||
|
30 s debounce of the bot-wide sync. Only touches the chat-scoped
|
||||||
|
binding (one Telegram API call); global per-language registrations
|
||||||
|
stay untouched. The bot-wide sync (``register_commands_with_telegram``)
|
||||||
|
remains the source of truth for everything else.
|
||||||
|
|
||||||
|
Returns ``True`` when Telegram acknowledged the change.
|
||||||
|
"""
|
||||||
|
from ..services.http_session import get_http_session
|
||||||
|
http = await get_http_session()
|
||||||
|
client = TelegramClient(http, bot.token)
|
||||||
|
|
||||||
|
scope = {"type": "chat", "chat_id": chat.chat_id}
|
||||||
|
|
||||||
|
# Chat is opted out of commands → ensure no chat-scoped override
|
||||||
|
# lingers. Telegram returns ok=true even if there was nothing to
|
||||||
|
# delete, so this is safe to call unconditionally.
|
||||||
|
if not chat.commands_enabled or not chat.language_override:
|
||||||
|
result = await client.delete_my_commands(scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"delete_my_commands(immediate) failed bot=%d chat=%s: %s",
|
||||||
|
bot.id, chat.chat_id, result.get("error"),
|
||||||
|
)
|
||||||
|
return bool(result.get("success"))
|
||||||
|
|
||||||
|
# Override active → resolve the command list for this bot in the
|
||||||
|
# override locale and push it scoped to this chat.
|
||||||
|
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||||
|
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||||
|
templates = _merge_all_templates(templates_by_config_id)
|
||||||
|
override_locale = _normalize_locale(chat.language_override)
|
||||||
|
commands = _build_command_list(enabled, templates, override_locale)
|
||||||
|
|
||||||
|
result = await client.set_my_commands(commands, scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"set_my_commands(immediate) failed bot=%d chat=%s locale=%s: %s",
|
||||||
|
bot.id, chat.chat_id, override_locale, result.get("error"),
|
||||||
|
)
|
||||||
|
return bool(result.get("success"))
|
||||||
|
|
||||||
|
|
||||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||||
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
|
"""Register enabled commands with Telegram BotFather API via TelegramClient.
|
||||||
|
|
||||||
|
Registration happens at three levels:
|
||||||
|
|
||||||
|
1. Default (no scope, no language) — fallback for any user.
|
||||||
|
2. Per-language (no scope, ``language_code=en|ru``) — Telegram picks
|
||||||
|
based on the *user's* Telegram client language.
|
||||||
|
3. Per-chat (``scope=BotCommandScopeChat``) — when a chat has
|
||||||
|
``language_override`` set, register chat-scoped commands so the
|
||||||
|
override takes effect regardless of each user's Telegram client
|
||||||
|
language. This is the only level Telegram honors for "this chat
|
||||||
|
should use RU even though the user's Telegram is in EN" — the
|
||||||
|
per-language registration alone is keyed on the client locale,
|
||||||
|
not on any per-chat preference we store.
|
||||||
|
"""
|
||||||
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||||
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||||
templates = _merge_all_templates(templates_by_config_id)
|
templates = _merge_all_templates(templates_by_config_id)
|
||||||
@@ -494,12 +574,9 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
|||||||
client = TelegramClient(http, bot.token)
|
client = TelegramClient(http, bot.token)
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# Register per-locale commands
|
# Register per-locale commands (keyed on user's Telegram client language)
|
||||||
for locale in ("en", "ru"):
|
for locale in ("en", "ru"):
|
||||||
commands = []
|
commands = _build_command_list(enabled, templates, locale)
|
||||||
for cmd in enabled:
|
|
||||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
|
||||||
commands.append({"command": cmd, "description": desc})
|
|
||||||
result = await client.set_my_commands(commands, language_code=locale)
|
result = await client.set_my_commands(commands, language_code=locale)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
success = True
|
success = True
|
||||||
@@ -507,13 +584,56 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
|||||||
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
||||||
|
|
||||||
# Register default (no language_code) with EN descriptions
|
# Register default (no language_code) with EN descriptions
|
||||||
en_commands = []
|
en_commands = _build_command_list(enabled, templates, "en")
|
||||||
for cmd in enabled:
|
|
||||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
|
||||||
en_commands.append({"command": cmd, "description": desc})
|
|
||||||
result = await client.set_my_commands(en_commands)
|
result = await client.set_my_commands(en_commands)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
|
# Per-chat overrides: apply chat-scoped commands so language_override
|
||||||
|
# wins over the user's Telegram client language. For chats with
|
||||||
|
# commands enabled but no override, clear any prior chat-scoped
|
||||||
|
# binding so they fall back to the per-language registration above.
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
chat_result = await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot.id,
|
||||||
|
TelegramChat.commands_enabled == True, # noqa: E712 — SQLModel needs == for column comparison
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chats = list(chat_result.all())
|
||||||
|
|
||||||
|
override_count = 0
|
||||||
|
for chat in chats:
|
||||||
|
scope = {"type": "chat", "chat_id": chat.chat_id}
|
||||||
|
if chat.language_override:
|
||||||
|
override_locale = _normalize_locale(chat.language_override)
|
||||||
|
commands = _build_command_list(enabled, templates, override_locale)
|
||||||
|
result = await client.set_my_commands(commands, scope=scope)
|
||||||
|
if result.get("success"):
|
||||||
|
override_count += 1
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Failed to register chat-scoped commands for bot=%d chat=%s locale=%s: %s",
|
||||||
|
bot.id, chat.chat_id, override_locale, result.get("error"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Clear any stale chat-scoped binding from a previous override
|
||||||
|
# so this chat falls back to the per-language registration.
|
||||||
|
# Telegram returns ok=true even when nothing was set; safe to
|
||||||
|
# call unconditionally.
|
||||||
|
result = await client.delete_my_commands(scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"delete_my_commands for bot=%d chat=%s returned: %s",
|
||||||
|
bot.id, chat.chat_id, result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if override_count:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Applied %d per-chat command override(s) for bot @%s",
|
||||||
|
override_count, bot.bot_username,
|
||||||
|
)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|||||||
@@ -76,16 +76,28 @@ def build_asset_dict(
|
|||||||
public_url: str = "",
|
public_url: str = "",
|
||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
|
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.
|
||||||
|
|
||||||
|
Asset-dict contract (shared with notification templates — see
|
||||||
|
``notify_bridge_core.templates.context``): templates may read either
|
||||||
|
``filename`` (the canonical field, used by notification defaults) or
|
||||||
|
``originalFileName`` (the historical command-default field); both are
|
||||||
|
populated so a custom template authored against either key keeps working.
|
||||||
|
Same story for ``created_at`` / ``createdAt``.
|
||||||
|
"""
|
||||||
if isinstance(asset, dict):
|
if isinstance(asset, dict):
|
||||||
# Immich raw search responses nest geo under exifInfo — pull it out so
|
# Immich raw search responses nest geo under exifInfo — pull it out so
|
||||||
# templates can use flat asset.city / asset.country.
|
# templates can use flat asset.city / asset.country.
|
||||||
exif = asset.get("exifInfo") or {}
|
exif = asset.get("exifInfo") or {}
|
||||||
|
fname = asset.get("originalFileName") or asset.get("filename") or ""
|
||||||
|
created = asset.get("createdAt") or asset.get("created_at") or asset.get("fileCreatedAt") or ""
|
||||||
d = {
|
d = {
|
||||||
"id": asset.get("id", ""),
|
"id": asset.get("id", ""),
|
||||||
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
|
"filename": fname,
|
||||||
|
"originalFileName": fname,
|
||||||
"type": asset.get("type", "IMAGE"),
|
"type": asset.get("type", "IMAGE"),
|
||||||
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
|
"created_at": created,
|
||||||
|
"createdAt": created,
|
||||||
"city": asset.get("city") or exif.get("city") or "",
|
"city": asset.get("city") or exif.get("city") or "",
|
||||||
"country": asset.get("country") or exif.get("country") or "",
|
"country": asset.get("country") or exif.get("country") or "",
|
||||||
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
|
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
|
||||||
@@ -97,8 +109,10 @@ def build_asset_dict(
|
|||||||
# ImmichAssetInfo dataclass
|
# ImmichAssetInfo dataclass
|
||||||
return {
|
return {
|
||||||
"id": asset.id,
|
"id": asset.id,
|
||||||
|
"filename": asset.filename,
|
||||||
"originalFileName": asset.filename,
|
"originalFileName": asset.filename,
|
||||||
"type": asset.type,
|
"type": asset.type,
|
||||||
|
"created_at": asset.created_at,
|
||||||
"createdAt": asset.created_at,
|
"createdAt": asset.created_at,
|
||||||
"city": getattr(asset, "city", "") or "",
|
"city": getattr(asset, "city", "") or "",
|
||||||
"country": getattr(asset, "country", "") or "",
|
"country": getattr(asset, "country", "") or "",
|
||||||
|
|||||||
@@ -71,11 +71,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
|
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
|
||||||
|
|
||||||
if await _has_table(conn, tracker_table):
|
if await _has_table(conn, tracker_table):
|
||||||
if not await _has_column(conn, tracker_table, "batch_duration"):
|
# NULL default = adaptive polling disabled for existing trackers.
|
||||||
|
# Operators who want the old back-off behavior can set a positive
|
||||||
|
# value per tracker from the UI.
|
||||||
|
if not await _has_column(conn, tracker_table, "adaptive_max_skip"):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(f"ALTER TABLE {tracker_table} ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
text(f"ALTER TABLE {tracker_table} ADD COLUMN adaptive_max_skip INTEGER")
|
||||||
)
|
)
|
||||||
logger.info("Added batch_duration column to %s table", tracker_table)
|
logger.info("Added adaptive_max_skip column to %s table", tracker_table)
|
||||||
|
|
||||||
# Add enriched fields to event_log if missing
|
# Add enriched fields to event_log if missing
|
||||||
if await _has_table(conn, "event_log"):
|
if await _has_table(conn, "event_log"):
|
||||||
@@ -144,7 +147,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
if bots:
|
if bots:
|
||||||
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
||||||
|
|
||||||
# Add update_mode to telegram_bot if missing
|
# Add update_mode to telegram_bot if missing.
|
||||||
|
# Existing bots pre-date this feature and were implicitly polling;
|
||||||
|
# preserve that behavior. New bots default to "none" via the
|
||||||
|
# SQLModel field default on fresh schemas.
|
||||||
if not await _has_column(conn, "telegram_bot", "update_mode"):
|
if not await _has_column(conn, "telegram_bot", "update_mode"):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
||||||
@@ -191,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added filters column to %s table", tracker_table)
|
logger.info("Added filters column to %s table", tracker_table)
|
||||||
|
|
||||||
|
# Drop legacy batch_duration column from notification_tracker.
|
||||||
|
# The field was removed from the SQLModel class but the column still
|
||||||
|
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
|
||||||
|
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
|
||||||
|
if await _has_table(conn, tracker_table):
|
||||||
|
if await _has_column(conn, tracker_table, "batch_duration"):
|
||||||
|
_assert_ident(tracker_table, "table")
|
||||||
|
await conn.execute(
|
||||||
|
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Dropped legacy batch_duration column from %s table",
|
||||||
|
tracker_table,
|
||||||
|
)
|
||||||
|
|
||||||
# Add Gitea tracking flags to tracking_config if missing
|
# Add Gitea tracking flags to tracking_config if missing
|
||||||
if await _has_table(conn, "tracking_config"):
|
if await _has_table(conn, "tracking_config"):
|
||||||
gitea_flags = [
|
gitea_flags = [
|
||||||
@@ -1370,6 +1391,40 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
|
||||||
|
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
|
||||||
|
|
||||||
|
Earlier versions of the frontend stored ``chat_action`` inside
|
||||||
|
``notification_target.config``; the dedicated ``chat_action`` column
|
||||||
|
was rarely set or held a stale default. The dispatcher's resolver
|
||||||
|
overrode the config value with the (stale) column, so a user's UI
|
||||||
|
choice silently had no effect on outgoing chat actions.
|
||||||
|
|
||||||
|
This backfill takes the config value as authoritative (it's what the
|
||||||
|
UI was writing) and copies it to the column, then strips it from
|
||||||
|
config so the column becomes the single source of truth. Idempotent:
|
||||||
|
a second run finds nothing to migrate.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
if not await _has_table(conn, "notification_target"):
|
||||||
|
return
|
||||||
|
if not await _has_column(conn, "notification_target", "chat_action"):
|
||||||
|
return
|
||||||
|
# Copy config["chat_action"] → column where present.
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE notification_target "
|
||||||
|
"SET chat_action = json_extract(config, '$.chat_action') "
|
||||||
|
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||||
|
))
|
||||||
|
# Strip the legacy key so the column is unambiguous going forward.
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE notification_target "
|
||||||
|
"SET config = json_remove(config, '$.chat_action') "
|
||||||
|
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||||
|
))
|
||||||
|
logger.info("Migrated chat_action from config JSON to column where present")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schema version tracking — lightweight alternative to Alembic while the
|
# Schema version tracking — lightweight alternative to Alembic while the
|
||||||
# hand-rolled idempotent migrations remain the source of truth. Gives
|
# hand-rolled idempotent migrations remain the source of truth. Gives
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user