Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b320090a56 | |||
| cc8d961c33 | |||
| 9eb478fdc9 | |||
| ef942b77cc | |||
| 711f218622 | |||
| 9eb76c1407 | |||
| d356e5a3ac | |||
| 9643fe519e | |||
| d662b50925 | |||
| 9733e5c122 | |||
| 46a4a6ee29 | |||
| 1895c5e2d4 | |||
| 0105d9f0ec | |||
| d3210fd5ea | |||
| d9ef3c6cc3 | |||
| 1e357244e1 |
+5
-32
@@ -1,36 +1,9 @@
|
||||
# v0.5.2 (2026-04-24)
|
||||
# v0.6.1 (2026-04-25)
|
||||
|
||||
Two related improvements to the notification-tracker stack: the display-filter fields on `TrackingConfig` (favorites-only, sort, max-assets, strip-tags, strip-asset-details) are now actually honored by every dispatch path — they previously existed in the model but were silently ignored on watcher / webhook / scheduled / memory / test fires. And the fixed `batch_duration` knob on `NotificationTracker` is replaced by a per-tracker `adaptive_max_skip`, so quiet trackers can opt into back-off without affecting busy ones.
|
||||
Small visual follow-up to the Aurora redesign: the **Active Wires pipe** on the dashboard now reads more prominently.
|
||||
|
||||
## Features
|
||||
## User-facing changes
|
||||
|
||||
- **Tracking-config display filters wired into every dispatch path** — the filter fields on Immich `TrackingConfig` now apply consistently across watcher events, inbound webhooks, scheduled / periodic / memory cron fires, and manual test dispatch ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||
- `favorites_only` drops events with no favorited new assets, or filters `added_assets` down to favorites only
|
||||
- `assets_order_by` / `assets_order` sort the rendered list (date / name / rating / random / none)
|
||||
- `max_assets_to_show` caps rendered + attached media (default raised from 5 → 10)
|
||||
- `include_tags` strips people from event extras and tags from each asset when disabled
|
||||
- `include_asset_details` strips `city` / `country` / `state` / `lat` / `lon` / `is_favorite` / `rating` / `description` when disabled — load-bearing fields (`thumbhash`, `file_size`, `playback_size`, cache keys) are preserved either way
|
||||
- New `apply_tracking_display_filters` helper in `dispatch_helpers` is the single source of truth
|
||||
- Targets sharing a `TrackingConfig` are dispatched together; targets with different configs each see their own shaped event
|
||||
- **Per-tracker adaptive polling** — replaces the global-feeling `NotificationTracker.batch_duration` with `adaptive_max_skip`, an opt-in cap on poll back-off ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||
- `NULL` / `0` → disabled, every tick runs (previous default behavior preserved)
|
||||
- Positive `N` → caps the skip factor at `(N-1)-in-N` after a long idle stretch
|
||||
- Scheduler caches the cap in module state for the tick fast-path
|
||||
- Migration adds the new column; API schemas / responses, frontend types, i18n, and the tracker form are all updated to match
|
||||
### Bug Fixes
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- **`batch_duration` → `adaptive_max_skip`** on `NotificationTracker`. The migration runs automatically; existing trackers default to disabled (every tick polls), matching previous behavior. Set a positive value per-tracker if you want quiet trackers to back off.
|
||||
- **Default `max_assets_to_show` is now 10** (was 5). Existing tracking configs with a stored value are unaffected; only the default for newly created configs (or unset fields) changes. If you relied on the 5-asset implicit cap, set it explicitly.
|
||||
- **Display filters now actually take effect.** If you had configured `favorites_only`, `include_tags`, `include_asset_details`, etc. previously and expected them to do something — they will now. Review your tracking configs after upgrade if you don't want the filtering applied.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
|
||||
| [ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6) | feat: wire tracking-config display filters + per-tracker adaptive polling | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
- Make Active Wires pipe visually prominent ([cc8d961](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc8d961))
|
||||
|
||||
@@ -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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
@@ -15,7 +15,10 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@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/newsreader": "^5.2.10",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"codemirror": "^6.0.2"
|
||||
},
|
||||
@@ -612,6 +615,22 @@
|
||||
"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": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
@@ -620,6 +639,14 @@
|
||||
"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": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
|
||||
@@ -1437,7 +1464,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
@@ -1560,7 +1587,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"dev": true,
|
||||
@@ -2865,11 +2892,26 @@
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
"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": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"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": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
|
||||
@@ -3375,7 +3417,7 @@
|
||||
}
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
@@ -3460,7 +3502,7 @@
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"dev": true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -35,7 +35,10 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@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/newsreader": "^5.2.10",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"codemirror": "^6.0.2"
|
||||
}
|
||||
|
||||
+331
-85
@@ -1,41 +1,80 @@
|
||||
@import '@fontsource/dm-sans/300.css';
|
||||
@import '@fontsource/dm-sans/400.css';
|
||||
@import '@fontsource/dm-sans/500.css';
|
||||
@import '@fontsource/dm-sans/600.css';
|
||||
@import '@fontsource/dm-sans/700.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
@import '@fontsource/jetbrains-mono/500.css';
|
||||
@import '@fontsource/jetbrains-mono/600.css';
|
||||
@import '@fontsource/geist-sans/300.css';
|
||||
@import '@fontsource/geist-sans/400.css';
|
||||
@import '@fontsource/geist-sans/500.css';
|
||||
@import '@fontsource/geist-sans/600.css';
|
||||
@import '@fontsource/geist-sans/700.css';
|
||||
@import '@fontsource/geist-mono/400.css';
|
||||
@import '@fontsource/geist-mono/500.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';
|
||||
|
||||
@theme {
|
||||
--color-background: #f8f9fb;
|
||||
--color-foreground: #1a1a2e;
|
||||
--color-muted: #eef0f4;
|
||||
--color-muted-foreground: #525866;
|
||||
--color-border: #e2e4ea;
|
||||
--color-primary: #0d9488;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-accent: #eef0f4;
|
||||
--color-accent-foreground: #1a1a2e;
|
||||
--color-destructive: #ef4444;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #1a1a2e;
|
||||
--color-success-bg: #ecfdf5;
|
||||
--color-success-fg: #059669;
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-warning-fg: #d97706;
|
||||
--color-error-bg: #fef2f2;
|
||||
--color-error-fg: #dc2626;
|
||||
--color-glow: rgba(13, 148, 136, 0.15);
|
||||
--color-glow-strong: rgba(13, 148, 136, 0.3);
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-active: rgba(13, 148, 136, 0.08);
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||
--radius: 0.625rem;
|
||||
/* Layered z-index scale — refer to these instead of ad-hoc numbers.
|
||||
Ordered: base < sticky < dropdown < overlay < modal < tooltip < toast */
|
||||
/* === AURORA: dark default ("Aurora") === */
|
||||
--color-background: #050613;
|
||||
--color-background-deep: #02030a;
|
||||
--color-foreground: #f3f1ff;
|
||||
--color-muted: rgba(255, 255, 255, 0.04);
|
||||
--color-muted-foreground: #b6b2d4;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Glass surfaces — replace solid card */
|
||||
--color-glass: rgba(255, 255, 255, 0.04);
|
||||
--color-glass-strong: rgba(255, 255, 255, 0.07);
|
||||
--color-glass-elev: rgba(255, 255, 255, 0.10);
|
||||
--color-highlight: rgba(255, 255, 255, 0.14);
|
||||
--color-input-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-rule-strong: rgba(255, 255, 255, 0.16);
|
||||
|
||||
/* Accent palette — soft pastel constellation */
|
||||
--color-primary: #b8a7ff; /* lavender — main accent */
|
||||
--color-primary-foreground: #02030a;
|
||||
--color-orchid: #ff9ec4;
|
||||
--color-mint: #7ee8c4;
|
||||
--color-citrus: #f0e16a;
|
||||
--color-coral: #ff8a78;
|
||||
--color-sky: #8ec9ff;
|
||||
|
||||
--color-accent: rgba(255, 255, 255, 0.07);
|
||||
--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-sticky: 10;
|
||||
--z-dropdown: 30;
|
||||
@@ -45,30 +84,56 @@
|
||||
--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"] {
|
||||
--color-background: #0c0e14;
|
||||
--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);
|
||||
/* defaults already match :root — no overrides needed, declaration kept for color-scheme */
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -78,68 +143,146 @@ body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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 {
|
||||
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: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.4;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
background: radial-gradient(circle at 50% 50%, transparent 30%, var(--color-background-deep) 100%);
|
||||
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 {
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-background);
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-input-bg);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
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 {
|
||||
outline: none;
|
||||
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-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Override browser autofill styles in dark mode */
|
||||
/* Override browser autofill in dark mode */
|
||||
[data-theme="dark"] input:-webkit-autofill,
|
||||
[data-theme="dark"] input:-webkit-autofill:hover,
|
||||
[data-theme="dark"] input:-webkit-autofill:focus,
|
||||
[data-theme="dark"] select:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px #13151e inset !important;
|
||||
-webkit-text-fill-color: #e4e6ed !important;
|
||||
caret-color: #e4e6ed;
|
||||
-webkit-box-shadow: 0 0 0 1000px #0d0e1c inset !important;
|
||||
-webkit-text-fill-color: #f3f1ff !important;
|
||||
caret-color: #f3f1ff;
|
||||
}
|
||||
|
||||
/* Color scheme for native controls */
|
||||
[data-theme="dark"] { color-scheme: dark; }
|
||||
[data-theme="light"] { color-scheme: light; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-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); }
|
||||
|
||||
/* 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 {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -160,6 +303,48 @@ a:focus-visible {
|
||||
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 {
|
||||
animation: fadeSlideIn 0.4s ease-out forwards;
|
||||
}
|
||||
@@ -178,9 +363,13 @@ a:focus-visible {
|
||||
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 > * {
|
||||
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(2) { animation-delay: 60ms; }
|
||||
@@ -193,10 +382,14 @@ a:focus-visible {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* Card highlight for cross-entity navigation */
|
||||
@keyframes cardHighlight {
|
||||
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 */
|
||||
@@ -213,3 +406,56 @@ a:focus-visible {
|
||||
.nav-dim-overlay.active {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
class?: string;
|
||||
} = $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> = {
|
||||
sm: 'px-2.5 py-1 text-xs',
|
||||
md: 'px-4 py-2',
|
||||
sm: 'aurora-btn--sm',
|
||||
md: 'aurora-btn--md',
|
||||
};
|
||||
const variantClasses: Record<string, string> = {
|
||||
primary: 'btn-primary',
|
||||
@@ -49,37 +49,72 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
.aurora-btn {
|
||||
border-radius: 12px;
|
||||
letter-spacing: -0.005em;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
.aurora-btn--sm {
|
||||
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 {
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
background: var(--color-glass-elev);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error-fg);
|
||||
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) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 28px -10px color-mix(in srgb, var(--color-error-fg) 60%, transparent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
<script lang="ts">
|
||||
let { children, class: className = '', hover = false, entityId = undefined, ...rest } = $props<{
|
||||
children: import('svelte').Snippet;
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
hover?: boolean;
|
||||
entityId?: number | string;
|
||||
[key: string]: any;
|
||||
}>();
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let { children, class: className = '', hover = false, entityId = undefined, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
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}
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
<div class="card-component__inner">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-component {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
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);
|
||||
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 {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 16px var(--color-glow), 0 0 0 1px var(--color-glow);
|
||||
border-color: var(--color-rule-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
const STATUS_MAP: Record<string, { icon: string; color: string; bg: string }> = {
|
||||
empty: { icon: 'mdiCircleOutline', color: 'var(--color-muted-foreground)', bg: 'transparent' },
|
||||
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)' },
|
||||
};
|
||||
const statusConfig = $derived(STATUS_MAP[status]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
export interface EntityItem {
|
||||
value: string | number;
|
||||
@@ -34,8 +35,8 @@
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let inputEl: HTMLInputElement;
|
||||
let listEl: HTMLDivElement;
|
||||
let inputEl = $state<HTMLInputElement | undefined>();
|
||||
let listEl = $state<HTMLDivElement | undefined>();
|
||||
|
||||
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>
|
||||
</button>
|
||||
|
||||
<!-- Palette overlay -->
|
||||
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
||||
{#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-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={18} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
class="ep-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<kbd class="ep-kbd">ESC</kbd>
|
||||
</div>
|
||||
<div class="ep-container">
|
||||
<div class="ep-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={18} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
class="ep-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<kbd class="ep-kbd">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div class="ep-list" bind:this={listEl} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<div class="ep-empty">{t('common.noMatches')}</div>
|
||||
{:else}
|
||||
{#each filtered as item, i}
|
||||
<button
|
||||
class="ep-item"
|
||||
class:ep-highlight={i === highlightIdx && !item.disabled}
|
||||
class:ep-current={String(item.value) === String(value)}
|
||||
class:ep-disabled={item.disabled}
|
||||
role="option"
|
||||
aria-selected={String(item.value) === String(value)}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
onclick={() => selectItem(item)}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
type="button"
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
|
||||
{/if}
|
||||
<span class="ep-item-label">{item.label}</span>
|
||||
{#if item.disabled && item.disabledHint}
|
||||
<span class="ep-item-hint">{item.disabledHint}</span>
|
||||
{:else if item.desc}
|
||||
<span class="ep-item-desc">{item.desc}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="ep-list" bind:this={listEl} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<div class="ep-empty">{t('common.noMatches')}</div>
|
||||
{:else}
|
||||
{#each filtered as item, i}
|
||||
<button
|
||||
class="ep-item"
|
||||
class:ep-highlight={i === highlightIdx && !item.disabled}
|
||||
class:ep-current={String(item.value) === String(value)}
|
||||
class:ep-disabled={item.disabled}
|
||||
role="option"
|
||||
aria-selected={String(item.value) === String(value)}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
onclick={() => selectItem(item)}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
type="button"
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
|
||||
{/if}
|
||||
<span class="ep-item-label">{item.label}</span>
|
||||
{#if item.disabled && item.disabledHint}
|
||||
<span class="ep-item-hint">{item.disabledHint}</span>
|
||||
{:else if item.desc}
|
||||
<span class="ep-item-desc">{item.desc}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -181,23 +184,25 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.15s;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.es-trigger.es-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.es-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-glass-strong);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
.es-trigger-icon {
|
||||
flex-shrink: 0;
|
||||
@@ -217,41 +222,63 @@
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.ep-overlay {
|
||||
/* Portal root — escapes any backdrop-filter ancestor */
|
||||
.es-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: min(20vh, 120px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: min(460px, 90vw);
|
||||
z-index: 1;
|
||||
width: min(480px, 92vw);
|
||||
max-height: 60vh;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
background: var(--ep-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 */
|
||||
.ep-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ep-input {
|
||||
flex: 1;
|
||||
@@ -261,14 +288,16 @@
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ep-input::placeholder { color: var(--color-muted-foreground); }
|
||||
.ep-kbd {
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -276,10 +305,12 @@
|
||||
.ep-list {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.25rem 0;
|
||||
padding: 0.35rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ep-empty {
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.85rem;
|
||||
@@ -289,20 +320,26 @@
|
||||
.ep-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: none;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.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 {
|
||||
opacity: 0.4;
|
||||
@@ -310,9 +347,14 @@
|
||||
}
|
||||
.ep-item.ep-disabled:hover {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.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 {
|
||||
flex-shrink: 0;
|
||||
@@ -320,19 +362,30 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.ep-item-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ep-item-desc {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { parseDate } from '$lib/api';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
interface DayData {
|
||||
date: string;
|
||||
@@ -13,11 +14,11 @@
|
||||
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
assets_added: '#059669',
|
||||
assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1',
|
||||
collection_deleted: '#dc2626',
|
||||
sharing_changed: '#f59e0b',
|
||||
assets_added: 'var(--color-mint)',
|
||||
assets_removed: 'var(--color-coral)',
|
||||
collection_renamed: 'var(--color-primary)',
|
||||
collection_deleted: 'var(--color-error-fg)',
|
||||
sharing_changed: 'var(--color-citrus)',
|
||||
};
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
@@ -128,28 +129,26 @@
|
||||
</div>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
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}
|
||||
<div use:portal>
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chart-wrapper {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.chart-wrapper:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 16px var(--color-glow);
|
||||
/* Outer chrome lives on the parent panel — keep this transparent so
|
||||
we don't get a double border / nested card look. */
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.chart-header {
|
||||
display: flex;
|
||||
@@ -248,16 +247,21 @@
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chart-tooltip {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
/* Tooltip is portalled to <body>, so use :global to make the style
|
||||
apply regardless of DOM location. */
|
||||
:global(.chart-tooltip) {
|
||||
--ct-solid-bg: #131520;
|
||||
background: var(--ct-solid-bg);
|
||||
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);
|
||||
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;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:global([data-theme="light"] .chart-tooltip) { --ct-solid-bg: #fafafe; }
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let { text = '' } = $props<{ text: string }>();
|
||||
let visible = $state(false);
|
||||
let tooltipStyle = $state('');
|
||||
let btnEl: HTMLButtonElement;
|
||||
let btnEl = $state<HTMLButtonElement | undefined>();
|
||||
const tooltipId = `hint-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
function show() {
|
||||
if (!btnEl) return;
|
||||
@@ -12,7 +15,7 @@
|
||||
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
if (left < 8) left = 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() {
|
||||
@@ -21,9 +24,7 @@
|
||||
</script>
|
||||
|
||||
<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
|
||||
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
|
||||
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
|
||||
class="hint-btn inline-flex items-center justify-center w-4 h-4 rounded-full text-[11px] font-bold leading-none
|
||||
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"
|
||||
onmouseenter={show}
|
||||
@@ -31,12 +32,41 @@
|
||||
onfocus={show}
|
||||
onblur={hide}
|
||||
aria-label={text}
|
||||
aria-describedby={visible ? tooltipId : undefined}
|
||||
title={text}
|
||||
tabindex="0"
|
||||
>?</button>
|
||||
|
||||
{#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;">
|
||||
{text}
|
||||
<div use:portal>
|
||||
<div id={tooltipId} role="tooltip" style={tooltipStyle} class="hint-tooltip">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
{/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">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
export interface GridItem {
|
||||
value: string | number;
|
||||
@@ -27,8 +28,8 @@
|
||||
|
||||
let open = $state(false);
|
||||
let search = $state('');
|
||||
let triggerEl: HTMLButtonElement;
|
||||
let searchEl: HTMLInputElement;
|
||||
let triggerEl = $state<HTMLButtonElement | undefined>();
|
||||
let searchEl = $state<HTMLInputElement | undefined>();
|
||||
let popupStyle = $state('');
|
||||
|
||||
const showSearch = $derived(items.length > 4);
|
||||
@@ -90,36 +91,39 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
role="presentation" onclick={() => open = false}></div>
|
||||
<!-- Backdrop + popup are portalled to <body> so they escape any
|
||||
backdrop-filter / transform ancestor that would otherwise act
|
||||
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;"
|
||||
class="icon-grid-popup">
|
||||
{#if showSearch}
|
||||
<input bind:this={searchEl} bind:value={search} placeholder="Filter..."
|
||||
class="icon-grid-search" type="text" autocomplete="off"
|
||||
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>
|
||||
<div style="{popupStyle} width: {columns * 160 + 16}px;"
|
||||
class="icon-grid-popup">
|
||||
{#if showSearch}
|
||||
<input bind:this={searchEl} bind:value={search} placeholder="Filter..."
|
||||
class="icon-grid-search" type="text" autocomplete="off"
|
||||
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">{t('common.noMatches')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -132,20 +136,21 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
background: var(--color-input-bg);
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.icon-grid-compact .icon-grid-trigger-label {
|
||||
flex: none;
|
||||
@@ -165,57 +170,93 @@
|
||||
color: var(--color-muted-foreground);
|
||||
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 {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
pointer-events: auto;
|
||||
/* Solid surface — popups need legibility, not glass translucency. */
|
||||
--igs-solid-bg: #131520;
|
||||
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;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.8rem;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.icon-grid-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid transparent;
|
||||
gap: 0.3rem;
|
||||
padding: 0.7rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-grid-cell:hover {
|
||||
background: var(--color-muted);
|
||||
transform: scale(1.03);
|
||||
background: var(--color-glass-strong);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.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);
|
||||
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 {
|
||||
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 {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -229,4 +270,11 @@
|
||||
color: var(--color-muted-foreground);
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup.svelte';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let { value = '', onselect } = $props<{
|
||||
value: string;
|
||||
@@ -34,7 +35,14 @@
|
||||
function toggleOpen() {
|
||||
if (!open && buttonEl) {
|
||||
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;
|
||||
if (!open) search = '';
|
||||
@@ -58,36 +66,158 @@
|
||||
|
||||
<div class="inline-block">
|
||||
<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)}
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg>
|
||||
{:else}
|
||||
<span class="text-[var(--color-muted-foreground)] text-xs">Icon</span>
|
||||
<span class="icon-picker-placeholder">Icon</span>
|
||||
{/if}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">▼</span>
|
||||
<span class="icon-picker-caret">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
role="presentation"
|
||||
onclick={() => { open = false; search = ''; }}></div>
|
||||
<!-- Portal popup so it escapes any backdrop-filter / transform ancestor
|
||||
that would otherwise act as the containing block for position:fixed. -->
|
||||
<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;"
|
||||
class="">
|
||||
<input type="text" bind:value={search} placeholder="Search icons..."
|
||||
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
<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('')}
|
||||
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>
|
||||
{#each filtered as iconName}
|
||||
<button type="button" onclick={() => select(iconName)}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
|
||||
title={iconName.replace('mdi', '')}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
|
||||
</button>
|
||||
{/each}
|
||||
<div style={dropdownStyle} class="ip-popup">
|
||||
<input type="text" bind:value={search} placeholder="Search icons..."
|
||||
class="ip-search" autocomplete="off" />
|
||||
<div class="ip-grid">
|
||||
<button type="button" onclick={() => select('')}
|
||||
class="ip-cell ip-cell--clear"
|
||||
title="No icon">✕</button>
|
||||
{#each filtered as iconName}
|
||||
<button type="button" onclick={() => select(iconName)}
|
||||
class="ip-cell {value === iconName ? 'is-active' : ''}"
|
||||
title={iconName.replace('mdi', '')}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/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.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);
|
||||
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));
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let { open = false, title = '', onclose, children } = $props<{
|
||||
open: boolean;
|
||||
@@ -11,7 +11,7 @@
|
||||
}>();
|
||||
|
||||
let visible = $state(false);
|
||||
let panelEl: HTMLDivElement;
|
||||
let panelEl = $state<HTMLDivElement | undefined>();
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
|
||||
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
||||
@@ -74,86 +74,143 @@
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
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 use:portal class="modal-portal-root">
|
||||
<div
|
||||
bind:this={panelEl}
|
||||
class="modal-panel"
|
||||
class="modal-backdrop"
|
||||
class:visible
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title-{uniqueId}"
|
||||
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;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onclick={onclose}
|
||||
onkeydown={handleBackdropKeydown}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;">
|
||||
<h3 id="modal-title-{uniqueId}" style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
||||
<button class="modal-close" onclick={onclose} aria-label={t('common.close')}>
|
||||
<MdiIcon name="mdiClose" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;">
|
||||
{@render children()}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={panelEl}
|
||||
class="modal-panel"
|
||||
class:visible
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
backdrop-filter: blur(0px);
|
||||
transition: background 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-backdrop.visible {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
|
||||
.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;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
var(--shadow-card),
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
:global([data-theme="light"]) .modal-panel { --modal-solid-bg: #fafafe; }
|
||||
|
||||
.modal-panel.visible {
|
||||
opacity: 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
export interface MultiEntityItem {
|
||||
value: string;
|
||||
@@ -26,8 +27,8 @@
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let inputEl: HTMLInputElement;
|
||||
let listEl: HTMLDivElement;
|
||||
let inputEl = $state<HTMLInputElement | undefined>();
|
||||
let listEl = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const selectedItems = $derived(items.filter(i => (values || []).includes(i.value)));
|
||||
|
||||
@@ -110,56 +111,58 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Palette overlay -->
|
||||
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
||||
{#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-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={18} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
placeholder={t('common.search')}
|
||||
class="mes-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<span class="mes-count">{(values || []).length}/{items.length}</span>
|
||||
<kbd class="mes-kbd">ESC</kbd>
|
||||
</div>
|
||||
<div class="mes-container">
|
||||
<div class="mes-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={18} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
placeholder={t('common.search')}
|
||||
class="mes-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<span class="mes-count">{(values || []).length}/{items.length}</span>
|
||||
<kbd class="mes-kbd">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div class="mes-list" bind:this={listEl} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<div class="mes-empty">{t('common.noMatches')}</div>
|
||||
{:else}
|
||||
{#each filtered as item, i}
|
||||
{@const checked = (values || []).includes(item.value)}
|
||||
<button
|
||||
class="mes-item"
|
||||
class:mes-highlight={i === highlightIdx}
|
||||
class:mes-checked={checked}
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
onclick={() => toggleItem(item)}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
type="button"
|
||||
>
|
||||
<span class="mes-item-check">
|
||||
<MdiIcon name={checked ? 'mdiCheckboxMarked' : 'mdiCheckboxBlankOutline'} size={16} />
|
||||
</span>
|
||||
{#if item.icon}
|
||||
<span class="mes-item-icon"><MdiIcon name={item.icon} size={18} /></span>
|
||||
{/if}
|
||||
<span class="mes-item-label">{item.label}</span>
|
||||
{#if item.desc}
|
||||
<span class="mes-item-desc">{item.desc}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="mes-list" bind:this={listEl} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<div class="mes-empty">{t('common.noMatches')}</div>
|
||||
{:else}
|
||||
{#each filtered as item, i}
|
||||
{@const checked = (values || []).includes(item.value)}
|
||||
<button
|
||||
class="mes-item"
|
||||
class:mes-highlight={i === highlightIdx}
|
||||
class:mes-checked={checked}
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
onclick={() => toggleItem(item)}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
type="button"
|
||||
>
|
||||
<span class="mes-item-check">
|
||||
<MdiIcon name={checked ? 'mdiCheckboxMarked' : 'mdiCheckboxBlankOutline'} size={16} />
|
||||
</span>
|
||||
{#if item.icon}
|
||||
<span class="mes-item-icon"><MdiIcon name={item.icon} size={18} /></span>
|
||||
{/if}
|
||||
<span class="mes-item-label">{item.label}</span>
|
||||
{#if item.desc}
|
||||
<span class="mes-item-desc">{item.desc}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -233,32 +236,42 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.mes-overlay {
|
||||
/* Portal root */
|
||||
.mes-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.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 {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: min(20vh, 120px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: min(460px, 90vw);
|
||||
z-index: 1;
|
||||
width: min(480px, 92vw);
|
||||
max-height: 60vh;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
background: var(--mes-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
--mes-solid-bg: #131520;
|
||||
}
|
||||
:global([data-theme="light"]) .mes-container { --mes-solid-bg: #fafafe; }
|
||||
|
||||
.mes-search-row {
|
||||
display: flex;
|
||||
@@ -319,7 +332,11 @@
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.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 {
|
||||
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">
|
||||
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;
|
||||
/** Italic-emphasized word(s) appended to the title with a gradient. */
|
||||
emphasis?: string;
|
||||
/** Body text under the title. */
|
||||
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>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="animate-fade-slide-in">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||
{#if description}
|
||||
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="animate-fade-slide-in" style="animation-delay: 60ms;">
|
||||
{@render children()}
|
||||
<section class="subpage-hero">
|
||||
<div class="subpage-hero__row">
|
||||
<div class="subpage-hero__main">
|
||||
{#if crumb}
|
||||
<div class="subpage-hero__crumb">{crumb}</div>
|
||||
{/if}
|
||||
<h2 class="subpage-hero__title">
|
||||
{title}{#if emphasis} <em>{emphasis}</em>{/if}
|
||||
</h2>
|
||||
{#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>
|
||||
{/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 activeIndex = $state(0);
|
||||
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
|
||||
$effect(() => { onopen?.(openPalette); });
|
||||
@@ -206,7 +208,7 @@
|
||||
|
||||
{#if open}
|
||||
<!-- 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 -->
|
||||
<div class="sp-container">
|
||||
@@ -218,11 +220,16 @@
|
||||
placeholder={t('searchPalette.placeholder')}
|
||||
class="sp-input"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="sp-results">
|
||||
<div class="sp-results" id={listboxId} role="listbox">
|
||||
{#if loading}
|
||||
<div class="sp-empty">
|
||||
<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} />
|
||||
{group.label}
|
||||
</div>
|
||||
{#each group.items as item, i}
|
||||
{#each group.items as item}
|
||||
{@const flatIdx = flatIndexMap.get(item) ?? -1}
|
||||
<button
|
||||
id={optionId(flatIdx)}
|
||||
role="option"
|
||||
aria-selected={flatIdx === activeIndex}
|
||||
class="sp-item"
|
||||
class:sp-active={flatIdx === activeIndex}
|
||||
onclick={() => navigateTo(item)}
|
||||
@@ -271,129 +281,175 @@
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
.sp-container {
|
||||
position: fixed;
|
||||
top: 20vh;
|
||||
top: 18vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: min(500px, 90vw);
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
width: min(640px, 92vw);
|
||||
--sp-solid-bg: #131520;
|
||||
background: var(--sp-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow-card), 0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.65rem;
|
||||
padding: 0.95rem 1.15rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.sp-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.sp-input::placeholder { color: var(--color-muted-foreground); }
|
||||
.sp-kbd {
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.sp-results {
|
||||
max-height: 50vh;
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.25rem;
|
||||
padding: 0.35rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.sp-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
gap: 0.55rem;
|
||||
padding: 2.5rem 2rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sp-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem 0.85rem 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.sp-item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sp-item-detail {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.1rem 0.35rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sp-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.6rem 1.15rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.sp-footer kbd {
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 0.2rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 5px;
|
||||
background: var(--color-glass);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
const snacks = $derived(getSnacks());
|
||||
|
||||
@@ -31,10 +32,7 @@
|
||||
</script>
|
||||
|
||||
{#if snacks.length > 0}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div use:portal class="snackbar-container">
|
||||
{#each snacks as snack (snack.id)}
|
||||
<div
|
||||
in:fly={{ y: 40, duration: 300 }}
|
||||
@@ -66,6 +64,16 @@
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
@@ -75,20 +83,21 @@
|
||||
}
|
||||
|
||||
.snack-item {
|
||||
--snack-solid-bg: #131520;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 14px;
|
||||
border-left: 3px solid var(--snack-accent);
|
||||
background: var(--color-card);
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
backdrop-filter: blur(12px);
|
||||
background: var(--snack-solid-bg);
|
||||
border-top: 1px solid var(--color-rule-strong);
|
||||
border-right: 1px solid var(--color-rule-strong);
|
||||
border-bottom: 1px solid var(--color-rule-strong);
|
||||
box-shadow: var(--shadow-card), 0 12px 30px -10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
:global([data-theme="light"]) .snack-item { --snack-solid-bg: #fafafe; }
|
||||
|
||||
: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);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"tagline": "Service notifications"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Overview",
|
||||
"sectionRouting": "Routing",
|
||||
"sectionOperators": "Operators",
|
||||
"sectionSystem": "System",
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"notificationTrackers": "Notif. Trackers",
|
||||
@@ -103,11 +107,46 @@
|
||||
"last14days": "Last 14 days",
|
||||
"event": "event",
|
||||
"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": {
|
||||
"title": "Providers",
|
||||
"description": "Manage service provider connections",
|
||||
"title": "Service",
|
||||
"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",
|
||||
"cancel": "Cancel",
|
||||
"type": "Provider Type",
|
||||
@@ -192,7 +231,10 @@
|
||||
"cleared": "Payload history cleared"
|
||||
},
|
||||
"notificationTracker": {
|
||||
"title": "Notification Trackers",
|
||||
"title": "Notification",
|
||||
"titleEmphasis": "trackers",
|
||||
"armed": "armed",
|
||||
"paused": "paused",
|
||||
"description": "Monitor albums for changes",
|
||||
"newTracker": "New Tracker",
|
||||
"cancel": "Cancel",
|
||||
@@ -304,6 +346,11 @@
|
||||
"albumDeleted": "Album deleted"
|
||||
},
|
||||
"targets": {
|
||||
"titleEmphasis": "channel",
|
||||
"titleEmphasisAll": "channels",
|
||||
"receiver": "receiver",
|
||||
"receivers": "receivers",
|
||||
"channelsCount": "channels",
|
||||
"title": "Targets",
|
||||
"description": "Notification delivery destinations",
|
||||
"descTelegram": "Telegram chat destinations for notifications",
|
||||
@@ -372,6 +419,8 @@
|
||||
"receiverDisabled": "Receiver disabled"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "& access",
|
||||
"countLabel": "users",
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"addUser": "Add User",
|
||||
@@ -389,6 +438,8 @@
|
||||
"noUsers": "No users found"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "bots",
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
@@ -465,6 +516,8 @@
|
||||
"webhookFailed": "Failed to register webhook"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"titleEmphasis": "configs",
|
||||
"countLabel": "configs",
|
||||
"title": "Tracking Configs",
|
||||
"description": "Define what events and assets to react to",
|
||||
"newConfig": "New Config",
|
||||
@@ -570,6 +623,8 @@
|
||||
"nextDay": "next day"
|
||||
},
|
||||
"templateConfig": {
|
||||
"titleEmphasis": "templates",
|
||||
"countLabel": "templates",
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"providerType": "Service Provider Type",
|
||||
@@ -689,6 +744,7 @@
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "options",
|
||||
"title": "Settings",
|
||||
"description": "Global application settings",
|
||||
"general": "General",
|
||||
@@ -765,6 +821,8 @@
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
"countLabel": "bots",
|
||||
"title": "Matrix Bots",
|
||||
"description": "Matrix homeserver connections for room notifications",
|
||||
"addBot": "Add Matrix Bot",
|
||||
@@ -781,6 +839,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "accounts",
|
||||
"title": "Email Bots",
|
||||
"description": "SMTP email senders for notifications",
|
||||
"addBot": "Add Email Bot",
|
||||
@@ -800,6 +860,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "templates",
|
||||
"countLabel": "templates",
|
||||
"title": "Command Templates",
|
||||
"description": "Customize command response messages with Jinja2 templates",
|
||||
"newConfig": "New Config",
|
||||
@@ -812,6 +874,8 @@
|
||||
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "configs",
|
||||
"countLabel": "configs",
|
||||
"title": "Command Configs",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
@@ -834,6 +898,7 @@
|
||||
"noTemplate": "Default (hardcoded)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "trackers",
|
||||
"title": "Command Trackers",
|
||||
"description": "Manage command trackers and their listeners",
|
||||
"newTracker": "New Tracker",
|
||||
@@ -1116,6 +1181,8 @@
|
||||
"close": "close"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "automations",
|
||||
"countLabel": "actions",
|
||||
"title": "Actions",
|
||||
"description": "Scheduled mutations on external services",
|
||||
"addAction": "Add Action",
|
||||
@@ -1173,6 +1240,7 @@
|
||||
"triggerScheduled": "scheduled"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "& restore",
|
||||
"title": "Backup & Restore",
|
||||
"description": "Export and import your configuration, or set up automatic backups",
|
||||
"export": "Export Configuration",
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"tagline": "Уведомления о сервисах"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Обзор",
|
||||
"sectionRouting": "Маршрутизация",
|
||||
"sectionOperators": "Операторы",
|
||||
"sectionSystem": "Система",
|
||||
"dashboard": "Главная",
|
||||
"providers": "Провайдеры",
|
||||
"notificationTrackers": "Трекеры увед.",
|
||||
@@ -103,11 +107,46 @@
|
||||
"last14days": "Последние 14 дней",
|
||||
"event": "событие",
|
||||
"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": {
|
||||
"title": "Провайдеры",
|
||||
"description": "Управление подключениями к сервисам",
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
"description": "Подключения к внешним сервисам и вебхукам. Каждый провайдер кормит трекеры событиями, которые рассылаются по вашим каналам.",
|
||||
"typeSingular": "тип",
|
||||
"typePlural": "типов",
|
||||
"addProvider": "Добавить провайдер",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип провайдера",
|
||||
@@ -192,6 +231,9 @@
|
||||
"cleared": "История запросов очищена"
|
||||
},
|
||||
"notificationTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"armed": "активны",
|
||||
"paused": "на паузе",
|
||||
"title": "Трекеры уведомлений",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -304,6 +346,11 @@
|
||||
"albumDeleted": "Альбом удалён"
|
||||
},
|
||||
"targets": {
|
||||
"titleEmphasis": "канал",
|
||||
"titleEmphasisAll": "каналы",
|
||||
"receiver": "получатель",
|
||||
"receivers": "получателей",
|
||||
"channelsCount": "каналов",
|
||||
"title": "Получатели",
|
||||
"description": "Адреса доставки уведомлений",
|
||||
"descTelegram": "Чаты Telegram для доставки уведомлений",
|
||||
@@ -372,6 +419,8 @@
|
||||
"receiverDisabled": "Получатель отключён"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "и доступ",
|
||||
"countLabel": "пользователей",
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"addUser": "Добавить пользователя",
|
||||
@@ -389,6 +438,8 @@
|
||||
"noUsers": "Пользователи не найдены"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "ботов",
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
@@ -465,6 +516,8 @@
|
||||
"webhookFailed": "Не удалось зарегистрировать webhook"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
@@ -570,6 +623,8 @@
|
||||
"nextDay": "след. день"
|
||||
},
|
||||
"templateConfig": {
|
||||
"titleEmphasis": "шаблоны",
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"providerType": "Тип сервис-провайдера",
|
||||
@@ -689,6 +744,7 @@
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "параметры",
|
||||
"title": "Настройки",
|
||||
"description": "Глобальные настройки приложения",
|
||||
"general": "Общие",
|
||||
@@ -765,6 +821,8 @@
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
"countLabel": "ботов",
|
||||
"title": "Matrix боты",
|
||||
"description": "Подключения к Matrix серверам для уведомлений в комнаты",
|
||||
"addBot": "Добавить Matrix бот",
|
||||
@@ -781,6 +839,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "учётных записей",
|
||||
"title": "Email боты",
|
||||
"description": "SMTP отправители для уведомлений по email",
|
||||
"addBot": "Добавить Email бот",
|
||||
@@ -800,6 +860,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "шаблоны",
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Шаблоны команд",
|
||||
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
||||
"newConfig": "Новый шаблон",
|
||||
@@ -812,6 +874,8 @@
|
||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
@@ -834,6 +898,7 @@
|
||||
"noTemplate": "По умолчанию (встроенный)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"title": "Трекеры команд",
|
||||
"description": "Управление трекерами команд и их слушателями",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -1116,6 +1181,8 @@
|
||||
"close": "закрыть"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "автоматизации",
|
||||
"countLabel": "действий",
|
||||
"title": "Действия",
|
||||
"description": "Запланированные операции над внешними сервисами",
|
||||
"addAction": "Добавить действие",
|
||||
@@ -1173,6 +1240,7 @@
|
||||
"triggerScheduled": "по расписанию"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "и восстановление",
|
||||
"title": "Резервное копирование",
|
||||
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
|
||||
"export": "Экспорт конфигурации",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
+455
-167
@@ -7,10 +7,10 @@
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { api } from '$lib/api';
|
||||
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 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 SearchPalette from '$lib/components/SearchPalette.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
@@ -21,6 +21,7 @@
|
||||
matrixBotsCache, targetsCache,
|
||||
} from '$lib/stores/caches.svelte';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { providerDefaultIcon } from '$lib/grid-items';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
@@ -201,6 +202,19 @@
|
||||
: 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)
|
||||
let expandedGroups = $state<Record<string, boolean>>({});
|
||||
|
||||
@@ -218,13 +232,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile: flatten nav for bottom bar (first 4 + "More" button)
|
||||
const mobileNavItems = $derived<NavItem[]>([
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
|
||||
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
]);
|
||||
// Mobile bottom-nav derives its 4 primary slots from baseNavEntries by key
|
||||
// lookup. Adding a new top-level nav entry doesn't break this list, and
|
||||
// renaming a key fails loudly via the assertion below — keeping desktop
|
||||
// and mobile nav structure in sync without manual duplication.
|
||||
const MOBILE_PRIMARY_KEYS = ['nav.dashboard', 'nav.notification', 'nav.commands', 'nav.targets'] as const;
|
||||
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
|
||||
// reachable on mobile (previously it was a flat hand-picked list that
|
||||
@@ -346,31 +367,34 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if auth.user}
|
||||
<div class="flex h-screen">
|
||||
<div class="app-shell">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
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 -->
|
||||
<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}
|
||||
<div class="animate-fade-slide-in">
|
||||
<h1 class="text-base font-semibold tracking-tight flex items-center gap-1.5" style="color: var(--color-foreground);">
|
||||
{#if globalProviderFilter.provider}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
|
||||
{/if}
|
||||
<span><span style="color: var(--color-primary);">Notify</span> Bridge</span>
|
||||
</h1>
|
||||
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
|
||||
<div class="animate-fade-slide-in flex items-center gap-3">
|
||||
<div class="brand-orb"></div>
|
||||
<div class="brand-text">
|
||||
<h1 class="brand-name">
|
||||
{#if globalProviderFilter.provider}
|
||||
<span class="brand-mark__icon" style="color: var(--color-primary);"><NavIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={14} /></span>
|
||||
{/if}
|
||||
Notify Bridge
|
||||
</h1>
|
||||
<p class="brand-version font-mono">v0.5.2</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if globalProviderFilter.provider}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
|
||||
{:else}
|
||||
<div class="brand-orb brand-orb--small"></div>
|
||||
{/if}
|
||||
<button onclick={toggleSidebar}
|
||||
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||
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')}
|
||||
aria-label={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
<NavIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -384,8 +408,9 @@
|
||||
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"
|
||||
title={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||
<MdiIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||
title={globalProviderFilter.provider?.name || t('common.allProviders')}
|
||||
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
|
||||
@@ -393,22 +418,12 @@
|
||||
</div>
|
||||
{/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 class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{#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)}
|
||||
<!-- Group header -->
|
||||
<button
|
||||
@@ -419,11 +434,11 @@
|
||||
{#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>
|
||||
{/if}
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<NavIcon name={entry.icon} size={18} />
|
||||
{#if !collapsed}
|
||||
<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'});">
|
||||
<MdiIcon name="mdiChevronRight" size={14} />
|
||||
<NavIcon name="mdiChevronRight" size={14} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -438,7 +453,7 @@
|
||||
{#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>
|
||||
{/if}
|
||||
<MdiIcon name={child.icon} size={15} />
|
||||
<NavIcon name={child.icon} size={15} />
|
||||
<span class="truncate flex-1">{t(child.key)}</span>
|
||||
{#if child.countKey && navCounts[child.countKey]}
|
||||
<span class="nav-badge-sm">{navCounts[child.countKey]}</span>
|
||||
@@ -457,7 +472,7 @@
|
||||
{#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>
|
||||
{/if}
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<NavIcon name={entry.icon} size={18} />
|
||||
{#if !collapsed}
|
||||
<span class="truncate flex-1">{t(entry.key)}</span>
|
||||
{#if entry.countKey && navCounts[entry.countKey]}
|
||||
@@ -470,61 +485,54 @@
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top: 1px solid var(--color-border);">
|
||||
<!-- Theme & Language -->
|
||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
|
||||
<button onclick={toggleLocale}
|
||||
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"
|
||||
title={t('common.language')}>
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={cycleTheme}
|
||||
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}
|
||||
<div class="sidebar-foot">
|
||||
{#if collapsed}
|
||||
<div class="flex flex-col items-center gap-1.5 py-3">
|
||||
<a href="/docs" target="_blank" rel="noopener noreferrer"
|
||||
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
|
||||
title={t('common.apiDocs')}
|
||||
aria-label={t('common.apiDocs')}>
|
||||
<NavIcon name="mdiApi" size={14} />
|
||||
</a>
|
||||
<button onclick={logout}
|
||||
class="sidebar-icon-btn w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||
title={t('nav.logout')}>
|
||||
<MdiIcon name="mdiLogout" size={16} />
|
||||
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
|
||||
title={t('nav.logout')}
|
||||
aria-label={t('nav.logout')}>
|
||||
<NavIcon name="mdiLogout" size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||
{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>
|
||||
{:else}
|
||||
<div class="user-card">
|
||||
<div class="user-card__main">
|
||||
<div class="user-avatar">
|
||||
{auth.user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<button onclick={() => showPasswordForm = true}
|
||||
class="change-pwd-link text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1">
|
||||
<MdiIcon name="mdiKeyVariant" size={12} />
|
||||
{t('common.changePassword')}
|
||||
<div class="user-card__text min-w-0">
|
||||
<p class="user-card__name truncate">{auth.user.username}</p>
|
||||
<p class="user-card__role">{auth.user.role}</p>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -534,18 +542,18 @@
|
||||
<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"
|
||||
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>
|
||||
{/each}
|
||||
<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"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiMagnify" size={20} />
|
||||
<NavIcon name="mdiMagnify" size={20} />
|
||||
</button>
|
||||
<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"
|
||||
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name="mdiDotsHorizontal" size={20} />
|
||||
<NavIcon name="mdiDotsHorizontal" size={20} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -553,7 +561,7 @@
|
||||
{#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);"
|
||||
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 }}>
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||
@@ -566,7 +574,7 @@
|
||||
<div>
|
||||
<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);">
|
||||
<MdiIcon name={entry.icon} size={13} />
|
||||
<NavIcon name={entry.icon} size={13} />
|
||||
<span>{t(entry.key)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@@ -575,7 +583,7 @@
|
||||
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'};"
|
||||
>
|
||||
<MdiIcon name={child.icon} size={20} />
|
||||
<NavIcon name={child.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||
{#if child.countKey && navCounts[child.countKey]}
|
||||
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||
@@ -589,7 +597,7 @@
|
||||
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'};"
|
||||
>
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<NavIcon name={entry.icon} size={18} />
|
||||
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||
{#if entry.countKey && navCounts[entry.countKey]}
|
||||
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||
@@ -601,7 +609,7 @@
|
||||
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={18} />
|
||||
<NavIcon name="mdiLogout" size={18} />
|
||||
<span class="text-sm">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -610,10 +618,30 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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));">
|
||||
|
||||
<!-- 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}
|
||||
<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()}
|
||||
</div>
|
||||
{/key}
|
||||
@@ -663,44 +691,103 @@
|
||||
<SearchPalette onopen={(fn) => openSearch = fn} />
|
||||
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
.mobile-nav { display: flex !important; }
|
||||
.mobile-more-panel a:hover,
|
||||
.mobile-more-panel button:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
/* === AURORA SHELL === */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Provider filter chips */
|
||||
.provider-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
/* === SIDEBAR — frosted glass rail === */
|
||||
.sidebar {
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--shadow-card);
|
||||
position: sticky;
|
||||
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;
|
||||
}
|
||||
.provider-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
.brand-mark__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.provider-chip.active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
.brand-version {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
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 {
|
||||
color: var(--color-muted-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
.provider-filter-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
|
||||
/* Sidebar icon button (toggle, logout) */
|
||||
@@ -709,88 +796,289 @@
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar-icon-btn:hover {
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Search button */
|
||||
.search-btn {
|
||||
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 links — soft glass hovers, gradient bar on active.
|
||||
Snapped from the Aurora dashboard mockup. */
|
||||
.nav-link {
|
||||
color: var(--color-muted-foreground);
|
||||
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 {
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--color-primary);
|
||||
color: var(--color-foreground);
|
||||
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 {
|
||||
background: var(--color-sidebar-active);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
|
||||
/* Footer pill buttons (locale, theme) */
|
||||
.footer-pill {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
/* Sidebar footer card */
|
||||
.sidebar-foot {
|
||||
padding: 0.85rem 0.85rem 1rem;
|
||||
}
|
||||
.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);
|
||||
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 */
|
||||
.change-pwd-link {
|
||||
/* Section labels above each nav group */
|
||||
.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);
|
||||
padding: 0.85rem 0.85rem 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.change-pwd-link:hover {
|
||||
color: var(--color-primary);
|
||||
.nav-section-label::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Primary action button (password form submit) */
|
||||
.primary-btn {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
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.3);
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-weight: 500;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
background: var(--color-glass-elev);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.2;
|
||||
min-width: 1.2rem;
|
||||
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 {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
padding: 0.05rem 0.35rem;
|
||||
font-weight: 500;
|
||||
padding: 0.06rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.2;
|
||||
min-width: 1rem;
|
||||
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>
|
||||
|
||||
+1249
-193
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,16 @@
|
||||
})());
|
||||
|
||||
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() {
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -171,7 +181,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('actions.addAction')}
|
||||
</Button>
|
||||
@@ -196,14 +214,14 @@
|
||||
{#if error}<ErrorBanner message={error} />{/if}
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
<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}
|
||||
placeholder={t('actions.selectProvider')} disabled={!!editing} />
|
||||
</div>
|
||||
|
||||
{#if actionTypes.length > 0}
|
||||
<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}
|
||||
<div class="space-y-1">
|
||||
{#each actionTypes as at}
|
||||
@@ -233,7 +251,7 @@
|
||||
</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">
|
||||
<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)]" />
|
||||
|
||||
@@ -153,8 +153,8 @@
|
||||
{#if showAddForm}
|
||||
<div class="border border-[var(--color-border)] rounded-md p-3 space-y-2 bg-[var(--color-muted)]/30">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
|
||||
<input bind:value={newRule.name} placeholder={t('actions.ruleNamePlaceholder')}
|
||||
<label for="rule-name-new" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
|
||||
@@ -189,8 +189,8 @@
|
||||
{#if expandedRule === rule.id}
|
||||
<div class="mt-2 pt-2 border-t border-[var(--color-border)] space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
|
||||
<input bind:value={rule.name}
|
||||
<label for="rule-name-{rule.id}" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
<!-- Person selector -->
|
||||
{#if personItems.length > 0}
|
||||
<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}
|
||||
bind:values={ruleConfig.criteria.person_ids}
|
||||
placeholder={t('actions.addPerson')}
|
||||
@@ -231,7 +231,7 @@
|
||||
|
||||
<!-- Person excludes -->
|
||||
<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}
|
||||
bind:values={ruleConfig.criteria.exclude_person_ids}
|
||||
placeholder={t('actions.addExcludePerson')}
|
||||
@@ -244,14 +244,14 @@
|
||||
|
||||
<!-- Smart search query -->
|
||||
<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')}
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<!-- Asset type -->
|
||||
<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}
|
||||
<label class="flex items-center gap-1 text-xs">
|
||||
<input type="radio"
|
||||
@@ -266,12 +266,12 @@
|
||||
<!-- Date range -->
|
||||
<div class="flex gap-2">
|
||||
<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}
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<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}
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@
|
||||
|
||||
{#if albumItems.length > 0}
|
||||
<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}
|
||||
bind:values={ruleConfig.target_album_ids}
|
||||
placeholder={t('actions.selectAlbumPlaceholder')}
|
||||
@@ -301,7 +301,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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}
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@
|
||||
|
||||
{#if ruleConfig.create_album_if_missing}
|
||||
<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}
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,14 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
|
||||
</Button>
|
||||
|
||||
@@ -84,7 +84,14 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
|
||||
</Button>
|
||||
|
||||
@@ -285,7 +285,14 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</Button>
|
||||
|
||||
@@ -80,6 +80,14 @@
|
||||
let hasCommands = $derived(providerCommands.length > 0);
|
||||
|
||||
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() {
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -161,7 +169,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('commandConfig.newConfig')}
|
||||
</Button>
|
||||
@@ -183,7 +199,7 @@
|
||||
</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}
|
||||
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
|
||||
{:else}
|
||||
@@ -208,30 +224,30 @@
|
||||
</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')} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<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 />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
|
||||
<input type="number" bind:value={form.default_count} min="1" max="20"
|
||||
<label for="cfg-default-count" class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.search} min="0" max="300"
|
||||
<label for="cfg-search-cooldown" class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-1/2 sm:w-1/4">
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
|
||||
<label for="cfg-default-cooldown" class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
@@ -140,6 +141,13 @@
|
||||
|
||||
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() {
|
||||
try {
|
||||
const [cfgs, caps, vars] = await Promise.all([
|
||||
@@ -355,11 +363,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
<PageHeader
|
||||
title={t('cmdTemplateConfig.title')}
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
@@ -385,7 +400,7 @@
|
||||
|
||||
{#if !editing}
|
||||
<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} />
|
||||
</div>
|
||||
{:else}
|
||||
@@ -469,7 +484,7 @@
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#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}
|
||||
<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}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -72,7 +73,24 @@
|
||||
.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 })));
|
||||
|
||||
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() {
|
||||
try {
|
||||
[allCmdTrackers] = await Promise.all([
|
||||
@@ -226,7 +244,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
|
||||
</Button>
|
||||
@@ -248,12 +274,12 @@
|
||||
</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')} />
|
||||
</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')} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -126,7 +127,25 @@
|
||||
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() {
|
||||
loadError = '';
|
||||
@@ -416,7 +435,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
|
||||
</Button>
|
||||
|
||||
@@ -129,13 +129,13 @@
|
||||
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<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}
|
||||
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||
</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}
|
||||
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||
|
||||
@@ -97,12 +97,12 @@
|
||||
</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')} />
|
||||
</div>
|
||||
{#if !isScheduler && colMeta && collections.length > 0}
|
||||
<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
|
||||
items={collections.map(col => ({
|
||||
value: col.id,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
|
||||
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 { highlightFromUrl } from '$lib/highlight';
|
||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||
@@ -54,7 +56,28 @@
|
||||
|
||||
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() {
|
||||
try {
|
||||
await providersCache.fetch(true);
|
||||
@@ -146,7 +169,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('providers.cancel') : t('providers.addProvider')}
|
||||
</Button>
|
||||
@@ -171,7 +202,7 @@
|
||||
<ErrorBanner message={error} />
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
<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}
|
||||
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
|
||||
{:else}
|
||||
@@ -216,7 +247,7 @@
|
||||
{/each}
|
||||
{#if descriptor?.webhookUrlPattern && editing}
|
||||
<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>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -93,7 +93,12 @@
|
||||
}
|
||||
</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}
|
||||
<Loading />
|
||||
|
||||
@@ -292,7 +292,12 @@
|
||||
}
|
||||
</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}
|
||||
<Loading />
|
||||
@@ -338,7 +343,7 @@
|
||||
<!-- Categories -->
|
||||
<div class="mb-4">
|
||||
<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}>
|
||||
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
||||
</button>
|
||||
@@ -355,7 +360,7 @@
|
||||
|
||||
<!-- Secrets mode -->
|
||||
<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">
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
||||
@@ -453,7 +458,7 @@
|
||||
|
||||
<!-- Conflict mode -->
|
||||
<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">
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="skip" />
|
||||
@@ -523,8 +528,8 @@
|
||||
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
||||
<select bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
||||
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
||||
<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)]">
|
||||
<option value="6">6 {t('backup.hours')}</option>
|
||||
<option value="12">12 {t('backup.hours')}</option>
|
||||
@@ -535,8 +540,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
||||
<select bind:value={scheduledSettings.backup_secrets_mode}
|
||||
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
||||
<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)]">
|
||||
<option value="exclude">{t('backup.secretsExclude')}</option>
|
||||
<option value="masked">{t('backup.secretsMasked')}</option>
|
||||
@@ -544,8 +549,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
||||
<select bind:value={scheduledSettings.backup_retention_count}
|
||||
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
||||
<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)]">
|
||||
<option value="3">3</option>
|
||||
<option value="5">5</option>
|
||||
@@ -651,6 +656,7 @@
|
||||
onclick={() => postRestoreModalOpen = false}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||
role="presentation">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<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);"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
@@ -134,7 +135,7 @@
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||
let formEl: HTMLElement;
|
||||
let formEl = $state<HTMLElement | undefined>();
|
||||
|
||||
async function scrollToForm() {
|
||||
await tick();
|
||||
@@ -165,6 +166,20 @@
|
||||
// ── Data loading ──
|
||||
|
||||
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() {
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -418,11 +433,18 @@
|
||||
}
|
||||
</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')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
<PageHeader
|
||||
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<form onsubmit={onsave} class="space-y-4">
|
||||
{#if !activeType}
|
||||
<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} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
{#if formType === 'telegram'}
|
||||
<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')} />
|
||||
{#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>
|
||||
@@ -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)]" />
|
||||
</div>
|
||||
<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 />
|
||||
</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>
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
{:else if formType === 'email'}
|
||||
<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')} />
|
||||
{#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>
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
{:else if formType === 'matrix'}
|
||||
<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')} />
|
||||
{#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>
|
||||
@@ -168,7 +168,7 @@
|
||||
{:else if formType === 'broadcast'}
|
||||
{@const childIds = (form.child_target_ids || []).map(String)}
|
||||
<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
|
||||
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
|
||||
values={childIds}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
@@ -207,7 +208,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() {
|
||||
try {
|
||||
[, varsRef] = await Promise.all([
|
||||
@@ -379,7 +395,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
|
||||
</Button>
|
||||
@@ -408,7 +432,7 @@
|
||||
|
||||
{#if !editing}
|
||||
<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} />
|
||||
</div>
|
||||
{:else}
|
||||
@@ -419,7 +443,7 @@
|
||||
{/if}
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
@@ -460,9 +484,9 @@
|
||||
{#if slot.isDateFormat}
|
||||
<div>
|
||||
<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>
|
||||
<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(); }}
|
||||
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]}
|
||||
@@ -508,7 +532,7 @@
|
||||
|
||||
{#if slotErrors[slot.key]}
|
||||
{#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}
|
||||
<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}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -190,7 +191,21 @@
|
||||
let form: Record<string, any> = $state(defaultForm());
|
||||
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() {
|
||||
try { await trackingConfigsCache.fetch(true); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
@@ -248,7 +263,15 @@
|
||||
}
|
||||
</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(); }}>
|
||||
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
||||
</Button>
|
||||
@@ -271,7 +294,7 @@
|
||||
</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}
|
||||
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
|
||||
{:else}
|
||||
|
||||
@@ -89,7 +89,14 @@
|
||||
}
|
||||
</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}>
|
||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.5.2"
|
||||
version = "0.6.1"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -829,12 +829,41 @@ class TelegramClient:
|
||||
|
||||
async def set_my_commands(
|
||||
self, commands: list[dict[str, str]], language_code: str | None = None,
|
||||
scope: dict[str, Any] | None = None,
|
||||
) -> 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"
|
||||
payload: dict[str, Any] = {"commands": commands}
|
||||
if 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:
|
||||
async with self._session.post(url, json=payload) as resp:
|
||||
data = await resp.json()
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.5.2"
|
||||
version = "0.6.1"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -11,7 +11,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -130,14 +134,28 @@ async def test_receiver(
|
||||
user: User = Depends(get_current_user),
|
||||
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)
|
||||
receiver = await session.get(TargetReceiver, receiver_id)
|
||||
if not receiver or receiver.target_id != target_id:
|
||||
raise HTTPException(status_code=404, detail="Receiver not found")
|
||||
|
||||
from ..services.notifier import _get_test_message
|
||||
effective_locale = getattr(receiver, 'locale', '') or locale
|
||||
if target.type == "telegram":
|
||||
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)
|
||||
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 ..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 ..database.engine import get_session
|
||||
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 .app_settings import get_setting
|
||||
from .helpers import get_owned_entity
|
||||
@@ -300,26 +300,14 @@ async def test_chat(
|
||||
):
|
||||
"""Send a test message to a chat via the bot.
|
||||
|
||||
Locale resolution: prefer the chat row's ``language_override`` (explicit
|
||||
user choice in the UI), fall back to Telegram's ``language_code`` sent
|
||||
with the chat, and only use the ``?locale=`` query param if neither is
|
||||
set. Otherwise users who set RU on a chat would still see an EN test.
|
||||
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)
|
||||
chat_row = (await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)).first()
|
||||
effective_locale = locale
|
||||
if chat_row:
|
||||
chat_locale = (
|
||||
getattr(chat_row, 'language_override', '') or
|
||||
getattr(chat_row, 'language_code', '') or ''
|
||||
)
|
||||
if chat_locale:
|
||||
effective_locale = chat_locale[:2].lower()
|
||||
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
|
||||
message = _get_test_message(effective_locale, "telegram")
|
||||
http = await get_http_session()
|
||||
@@ -347,11 +335,37 @@ async def update_chat(
|
||||
if not chat or chat.bot_id != bot_id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
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():
|
||||
setattr(chat, key, value)
|
||||
session.add(chat)
|
||||
await session.commit()
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from ..database.models import (
|
||||
NotificationTracker,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
TelegramChat,
|
||||
)
|
||||
from .base import CommandResponse
|
||||
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:
|
||||
"""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)
|
||||
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||
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)
|
||||
success = False
|
||||
|
||||
# Register per-locale commands
|
||||
# Register per-locale commands (keyed on user's Telegram client language)
|
||||
for locale in ("en", "ru"):
|
||||
commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||
commands.append({"command": cmd, "description": desc})
|
||||
commands = _build_command_list(enabled, templates, locale)
|
||||
result = await client.set_my_commands(commands, language_code=locale)
|
||||
if result.get("success"):
|
||||
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"))
|
||||
|
||||
# Register default (no language_code) with EN descriptions
|
||||
en_commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
||||
en_commands.append({"command": cmd, "description": desc})
|
||||
en_commands = _build_command_list(enabled, templates, "en")
|
||||
result = await client.set_my_commands(en_commands)
|
||||
if result.get("success"):
|
||||
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
||||
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
|
||||
|
||||
@@ -43,6 +43,62 @@ def _get_test_message(locale: str, target_type: str) -> str:
|
||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||
|
||||
|
||||
def pick_telegram_locale(
|
||||
*,
|
||||
receiver_locale: str = "",
|
||||
chat_override: str = "",
|
||||
chat_language_code: str = "",
|
||||
fallback: str = "en",
|
||||
) -> str:
|
||||
"""Pick the effective 2-letter locale for a Telegram chat.
|
||||
|
||||
Priority (highest first):
|
||||
1. ``receiver_locale`` — explicit per-receiver override on a target.
|
||||
2. ``chat_override`` — explicit ``TelegramChat.language_override``
|
||||
set in the bot/chat manager UI.
|
||||
3. ``chat_language_code`` — Telegram-provided ``language_code``.
|
||||
4. ``fallback`` — caller-supplied default (e.g. query param).
|
||||
|
||||
All inputs are coerced to lowercase 2-letter codes.
|
||||
"""
|
||||
for candidate in (receiver_locale, chat_override, chat_language_code, fallback):
|
||||
if candidate:
|
||||
return candidate[:2].lower()
|
||||
return "en"
|
||||
|
||||
|
||||
async def resolve_telegram_chat_locale(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
bot_id: int | None,
|
||||
chat_id: str | int | None,
|
||||
receiver: TargetReceiver | None = None,
|
||||
fallback: str = "en",
|
||||
) -> str:
|
||||
"""Look up a Telegram chat and resolve its effective locale.
|
||||
|
||||
Single source of truth for "what language should I send to this chat in?".
|
||||
Used by every Telegram test/preview path (bot test_chat, target test
|
||||
receiver, per-receiver fan-out) so they stay in lockstep.
|
||||
"""
|
||||
from ..database.models import TelegramChat
|
||||
|
||||
chat_row = None
|
||||
if bot_id and chat_id:
|
||||
chat_row = (await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == str(chat_id),
|
||||
)
|
||||
)).first()
|
||||
return pick_telegram_locale(
|
||||
receiver_locale=getattr(receiver, "locale", "") if receiver else "",
|
||||
chat_override=getattr(chat_row, "language_override", "") if chat_row else "",
|
||||
chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "",
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
async def _load_receivers(target_id: int) -> list[dict]:
|
||||
"""Load enabled receivers for a target from DB."""
|
||||
engine = get_engine()
|
||||
@@ -343,9 +399,12 @@ async def _send_telegram_test_per_receiver(
|
||||
if not recv_rows:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
# Resolve per-receiver locale
|
||||
# Batch-load TelegramChat rows so per-receiver locale picks don't
|
||||
# round-trip the DB N times. Priority resolution then runs through the
|
||||
# shared pick_telegram_locale() helper so single-shot test endpoints
|
||||
# and this fan-out agree on the same rules.
|
||||
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||
chat_locale_map: dict[str, str] = {}
|
||||
chat_row_map: dict[str, TelegramChat] = {}
|
||||
if bot_id and chat_ids:
|
||||
chat_rows = (await session.exec(
|
||||
select(TelegramChat).where(
|
||||
@@ -353,13 +412,7 @@ async def _send_telegram_test_per_receiver(
|
||||
TelegramChat.chat_id.in_(chat_ids),
|
||||
)
|
||||
)).all()
|
||||
for chat in chat_rows:
|
||||
override = (
|
||||
getattr(chat, "language_override", "") or
|
||||
getattr(chat, "language_code", "") or ""
|
||||
)
|
||||
if override:
|
||||
chat_locale_map[chat.chat_id] = override[:2].lower()
|
||||
chat_row_map = {chat.chat_id: chat for chat in chat_rows}
|
||||
|
||||
http = await get_http_session()
|
||||
client = TelegramClient(http, bot_token)
|
||||
@@ -374,9 +427,14 @@ async def _send_telegram_test_per_receiver(
|
||||
chat_id = str(r.config.get("chat_id", ""))
|
||||
if not chat_id:
|
||||
return None
|
||||
explicit = getattr(r, "locale", "") or ""
|
||||
locale = explicit or chat_locale_map.get(chat_id) or default_locale
|
||||
message = _get_test_message(locale[:2].lower(), "telegram")
|
||||
chat_row = chat_row_map.get(chat_id)
|
||||
locale = pick_telegram_locale(
|
||||
receiver_locale=getattr(r, "locale", "") or "",
|
||||
chat_override=getattr(chat_row, "language_override", "") if chat_row else "",
|
||||
chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "",
|
||||
fallback=default_locale,
|
||||
)
|
||||
message = _get_test_message(locale, "telegram")
|
||||
async with sem:
|
||||
return await client.send_message(
|
||||
chat_id=chat_id,
|
||||
|
||||
Reference in New Issue
Block a user