Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 757271dadf | |||
| 73b046f7a2 | |||
| b170c2b792 | |||
| 35a3008896 | |||
| 632e4c1aa3 | |||
| 0eb899afb9 | |||
| 5bd63a2191 | |||
| 349e9136a4 | |||
| 04c8e3c8b2 | |||
| 9afd38e50e | |||
| aa9548d884 | |||
| 72dd611f8c | |||
| 0e675c4b38 | |||
| 4307955163 | |||
| b107b01a00 | |||
| 42af7a6551 | |||
| c43dc598a1 | |||
| 1bfec521d8 | |||
| b320090a56 | |||
| cc8d961c33 | |||
| 9eb478fdc9 | |||
| ef942b77cc | |||
| 711f218622 | |||
| 9eb76c1407 | |||
| d356e5a3ac | |||
| 9643fe519e | |||
| d662b50925 | |||
| 9733e5c122 | |||
| 46a4a6ee29 | |||
| 1895c5e2d4 | |||
| 0105d9f0ec | |||
| d3210fd5ea | |||
| d9ef3c6cc3 | |||
| 1e357244e1 | |||
| 770c198ac3 | |||
| ab621b6abc | |||
| 187b889c45 | |||
| b61394f057 | |||
| be15463fd2 | |||
| 461fb495d7 | |||
| 309dec2b44 | |||
| 90def11b8d | |||
| 8f0346ea03 | |||
| a6a854ad21 | |||
| 19036a90bb | |||
| 592e1b6114 | |||
| bbcdf1c5d1 | |||
| f9040370bc | |||
| 3b683ce82c | |||
| 2bec25353b | |||
| e44d387c7f | |||
| 7cbb02b1ef | |||
| 920920bc67 | |||
| f50d465c0e | |||
| 1f880daa0c | |||
| 1024085cdd | |||
| 5604c733d1 | |||
| 3b7808aa9c | |||
| 155d25edf9 | |||
| 69711bbc84 | |||
| fe38d20b96 | |||
| d02616069d | |||
| 7dae68fd93 | |||
| e6481605ca | |||
| 6de9a1289e |
@@ -1,8 +1,8 @@
|
||||
# Entity Relationships
|
||||
|
||||
```
|
||||
```text
|
||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
||||
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
|
||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||
TrackingConfig → provider_type, event flags, scheduling rules
|
||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
name: Build Docker Image
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test-frontend:
|
||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t notify-bridge:dev .
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
- name: Svelte check
|
||||
run: |
|
||||
cd frontend
|
||||
npm run check || echo "::warning::svelte-check reported warnings"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
build-image:
|
||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||
needs: [test-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image (no push)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: notify-bridge:ci-${{ gitea.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ gitea.sha }}
|
||||
${{ steps.version.outputs.is_pre == 'false' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
@@ -56,3 +56,5 @@ frontend/.svelte-kit/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,3 +43,42 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
||||
- Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`)
|
||||
- Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`)
|
||||
- Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py`
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+48
-4
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# =============================================================================
|
||||
# Stage 1: Build frontend (SvelteKit static output)
|
||||
# =============================================================================
|
||||
@@ -14,7 +15,7 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Build Python wheels
|
||||
# Stage 2: Build Python wheels + extract external dependency list
|
||||
# =============================================================================
|
||||
FROM python:3.12-slim AS python-build
|
||||
|
||||
@@ -30,16 +31,59 @@ RUN python -m build packages/core/ --wheel --outdir /wheels
|
||||
COPY packages/server/ packages/server/
|
||||
RUN python -m build packages/server/ --wheel --outdir /wheels
|
||||
|
||||
# Emit /wheels/deps.txt with ONLY external (PyPI) deps — filter out
|
||||
# notify-bridge-* siblings, which are installed from local wheels below.
|
||||
# This file is the cache key for the external-deps install layer: as long as
|
||||
# pyproject.toml dependency lines don't change, the runtime install layer is
|
||||
# served from registry buildcache and no wheels are re-downloaded.
|
||||
RUN python <<'PY'
|
||||
import tomllib
|
||||
|
||||
deps: list[str] = []
|
||||
for p in ("packages/core/pyproject.toml", "packages/server/pyproject.toml"):
|
||||
with open(p, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
for d in data["project"].get("dependencies", []):
|
||||
if not d.lstrip().lower().startswith("notify-bridge-"):
|
||||
deps.append(d)
|
||||
|
||||
seen: set[str] = set()
|
||||
with open("/wheels/deps.txt", "w") as f:
|
||||
for d in deps:
|
||||
if d not in seen:
|
||||
seen.add(d)
|
||||
f.write(d + "\n")
|
||||
PY
|
||||
|
||||
# =============================================================================
|
||||
# Stage 3: Runtime
|
||||
# =============================================================================
|
||||
FROM python:3.12-slim
|
||||
|
||||
# uv — fast pip replacement. Installed from PyPI (Fastly CDN) rather than
|
||||
# ghcr.io/astral-sh/uv, because GHCR pulls from this runner crawl at a few
|
||||
# hundred KB/s and take longer than the install savings would recoup.
|
||||
RUN pip install --no-cache-dir uv==0.11.7
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install wheels
|
||||
COPY --from=python-build /wheels/ /tmp/wheels/
|
||||
RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
|
||||
# Install external deps first — layer cache key is deps.txt content, which
|
||||
# only changes when pyproject.toml dependency lines change (not on version
|
||||
# bumps). The cache mount persists downloaded wheels across local rebuilds;
|
||||
# in CI, the registry buildcache serves the whole layer when unchanged.
|
||||
COPY --from=python-build /wheels/deps.txt /tmp/deps.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install --system -r /tmp/deps.txt \
|
||||
&& rm /tmp/deps.txt
|
||||
|
||||
# Install local wheels without re-resolving — all external deps are present.
|
||||
COPY --from=python-build /wheels/*.whl /tmp/wheels/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install --system --no-deps /tmp/wheels/*.whl \
|
||||
&& rm -rf /tmp/wheels
|
||||
|
||||
# Copy frontend build
|
||||
COPY --from=frontend-build /build/build/ /app/static/
|
||||
|
||||
+30
-15
@@ -1,27 +1,42 @@
|
||||
# v0.2.6 (2026-04-22)
|
||||
# v0.7.1 (2026-05-07)
|
||||
|
||||
Bug-fix release. Notably: saving settings was silently overwriting the
|
||||
Telegram webhook secret with its own display mask, invalidating HMAC on
|
||||
every webhook-mode bot after any settings save. Also fixes template-editor
|
||||
variable discovery for provider-specific command slots (`/search`, `/status`,
|
||||
`/repos`, `/issues`, `/boards`), asset enrichment (city / country / favorite)
|
||||
for Immich `/search` / `/find` / `/person` / `/place`, and video rendering
|
||||
in command media groups.
|
||||
## Features
|
||||
|
||||
- Bot command invocations now appear in the dashboard event stream with `command_handled`, `command_rate_limited`, and `command_failed` rows — closing the last user-initiated path that was invisible to the dashboard ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Click any event row to open a detail modal with full provenance (bot → chat → issuer → provider), raw `details` JSON, and per-entity action buttons that deep-link into the relevant list page with the card scrolled into view and pulsing ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Configurable event auto-refresh dropdown (Off / 10s / 30s / 1m / 5m), persisted in `localStorage`; ticker pauses while the tab is hidden ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Smoother event refresh — no more loading-placeholder flicker on auto-refresh; unchanged rows reuse their DOM nodes and identical pages skip re-rendering entirely ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Page header breadcrumbs are now translated (new `crumbs.*` i18n namespace covering all 15 call sites), so `Routing · Notification`, `Operators · Bots`, etc. switch with the active language ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Tracker form's Immich feature-discovery banner now offers an `Open Template Config` shortcut alongside `Open Tracking Config`, and `/template-configs?edit=<id>` auto-opens the editor on landing ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Event-type filter, dashboard verb labels, and gradients extended for the three new `command_*` types; filled in previously missing i18n keys (`common.hide`, `common.show`, `commandConfig.noCommandsForProvider`) ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Telegram issuer info (`from`) captured in both poller and webhook paths and persisted under `details.issuer`, whitelisted to identity fields only by `_normalize_issuer` so `language_code` and any future PII fields are dropped ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Don't clobber the Telegram webhook secret with its mask on save** — `GET /settings` returns the secret masked as `***<last4>`; the frontend bound that masked value into state and shipped it back on any Save, so the PUT handler persisted the mask as the new secret. The next GET re-masked the mask to itself, so the UI showed no corruption while HMAC verification silently broke for every webhook-mode bot. Incoming values that begin with `***` are now treated as *unchanged*; empty strings still clear the secret explicitly. **Operators running webhook-mode bots should save the page once with a known-good secret after upgrading.** ([8531168](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8531168))
|
||||
- **Surface Variables button / autocomplete for provider-specific command slots** — the command-template-configs UI only resolved slot variables against the shared catalog, so Immich's `/search` and `/status`, Gitea's `/repos` / `/issues`, and Planka's `/boards` offered no autocomplete. It now resolves against the active provider (`varsRef[provider_type][slot]`) first, falling back to shared entries. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Enrich raw Immich search results through `build_asset_dict`** — `/search`, `/find`, `/person`, `/place` previously handed raw API rows to templates, so `city` / `country` (from `exifInfo`) and `is_favorite` (mapped from `isFavorite`) were missing and templates couldn't render location or favorite indicators. Now normalised the same way as notification events. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Videos render correctly in command media groups** — `/latest`, `/random`, `/favorites` were sending videos as still thumbnails because the media-group path duplicated asset-typing logic. Extracted `build_telegram_asset_entry` into a shared helper so the notification dispatcher and command groups agree on video typing and `/video/playback` URLs. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Command media groups reuse the Telegram `file_id` cache** — `send_media_group` was re-uploading assets on every repeat command instead of honoring the cache the notification dispatcher already populates. Now shares the cache, avoiding re-upload churn. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- Cyrillic glyphs in sidebar nav links, section labels, and monospace badges now render in Geist instead of falling back to Segoe UI / Cascadia / Consolas. Switched to `@fontsource-variable/geist` (latin + latin-ext + cyrillic) and added `@fontsource/geist-mono` cyrillic subsets for weights 400/500/600 ([73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f))
|
||||
|
||||
---
|
||||
|
||||
## Development / Internal
|
||||
|
||||
### Database
|
||||
|
||||
- `EventLog` gains nullable `command_tracker_id` / `telegram_bot_id` FKs plus deletion-snapshot name columns; idempotent migration ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- `/api/status` resolves live `CommandTracker` / `TelegramBot` names (mirroring the action pattern) and exposes `tracker_id`, `command_tracker_id`, `telegram_bot_id` so the frontend can deep-link ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
### Tests
|
||||
|
||||
- New `test_command_event_logging.py` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable; full server suite passing 96/96 ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
- [fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169) — fix(commands): enrich search assets, surface variables for all command slots *(alexei.dolgolyov)*
|
||||
- [8531168](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8531168) — fix(settings): don't clobber webhook secret with its mask on save *(alexei.dolgolyov)*
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f) | fix(frontend): cyrillic glyphs for nav and section labels | alexei.dolgolyov |
|
||||
| [b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b) | feat(frontend): smoother event refresh, localized crumbs, template config deep-link | alexei.dolgolyov |
|
||||
| [35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008) | feat: log bot command invocations to the event stream | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Notify Bridge — Redesign Mockups
|
||||
|
||||
**Start here:** open [`index.html`](./index.html) for the chooser. Three full directions to pick between, plus a side-by-side comparison table.
|
||||
|
||||
**Direction chosen: Aurora / Glass** (2026-04-25). Continuing to mock additional surfaces in this language; original three-way chooser kept for reference.
|
||||
|
||||
| File | Option | Mood |
|
||||
| --- | --- | --- |
|
||||
| [`index.html`](./index.html) | **Chooser** | Compare all three side by side |
|
||||
| [`dashboard.html`](./dashboard.html) | A · Bridge / Control Room | Editorial broadcast console — phosphor lime on deep ink, italic Fraunces, hairlines, scanlines |
|
||||
| [`dashboard-aurora.html`](./dashboard-aurora.html) | **B · Aurora / Glass** ✓ | Frosted-glass panels over a vivid aurora gradient — visionOS / Stripe-modern |
|
||||
| [`dashboard-bento.html`](./dashboard-bento.html) | C · Bento / Modular | Mixed-size colorful tiles in a tight grid — Apple Keynote / Linear blog energy |
|
||||
| [`aurora-tracker.html`](./aurora-tracker.html) | **Aurora · Tracker detail** | Form + live preview + event log — stress-tests glass on form-heavy surfaces |
|
||||
|
||||
All three are self-contained HTML — no build step. Each has its own theme toggle in the top-right.
|
||||
|
||||
---
|
||||
|
||||
## Quick comparison
|
||||
|
||||
| Trait | A · Bridge | B · Aurora | C · Bento |
|
||||
| --- | --- | --- | --- |
|
||||
| Mood | Editorial / operator | Premium / atmospheric | Playful / confident |
|
||||
| Default theme | Dark (Console) | Dark (Aurora) | Light (Daylight) |
|
||||
| Accent | Phosphor lime `#d4ff3a` | Lavender + orchid + mint | Violet · mint · coral · honey |
|
||||
| Surface | Hairline-rule modules | Frosted-glass panels | Solid-color tiles |
|
||||
| Display font | Fraunces (serif) | Newsreader (serif) | Manrope (sans) |
|
||||
| Density | High · for power users | Medium · breathable | Medium · airy |
|
||||
| Best for | Pro operators · self-hosters | Showroom · public-facing | Mainstream · cross-audience |
|
||||
| Risk | Niche taste · heavy mood | Glass trend may date | Color discipline matters |
|
||||
|
||||
---
|
||||
|
||||
## What all three share (the UX, not the paint)
|
||||
|
||||
These additions are the same across every option — pick a *look*, not a different *product*:
|
||||
|
||||
1. **Live ticker / "live" pill** — always-running awareness of the last events without forcing focus
|
||||
2. **Stats with deltas + sparklines or trend chart** — numbers always have context
|
||||
3. **Editorial hero** with current-state sentence + big throughput readout
|
||||
4. **Signal stream with routing trail** — every event shows Tracker → Target → Template inline (today: 3 clicks to find this)
|
||||
5. **Provider deck** — throughput, last-seen, pulse status, idle/warn/live indicators
|
||||
6. **Pulse chart** (heatmap in A, area waves in B/C) — finally answers "when is this thing busiest?"
|
||||
7. **Active wires panel** — Sankey-style Source → Channel routes with live counts
|
||||
8. **Compose / new-tracker CTA** — single entry to a 4-step wizard (provider → tracker → template → target)
|
||||
9. **Two-theme system** — committed light + dark per option, no lukewarm middle "system"
|
||||
|
||||
---
|
||||
|
||||
## Implementation cost (rough)
|
||||
|
||||
| Option | New deps | New components | Migration risk |
|
||||
| --- | --- | --- | --- |
|
||||
| A · Bridge | Fraunces + Instrument Sans + JetBrains Mono fonts | Ticker, sparklines, signal-stream-with-trail, heatmap, routes panel | Low — mostly token swap + the 5 new components |
|
||||
| B · Aurora | Newsreader + Geist + Geist Mono fonts | Same as A + heavy backdrop-filter / glass system | Medium — `backdrop-filter` perf needs review on long lists; gradient bg can hurt low-end devices |
|
||||
| C · Bento | Manrope + JetBrains Mono fonts | Same UX components, but tile-grid layout system + bold-color discipline (color governance matters more) | Low-Medium — tile spans need a discipline, and 8-color palette needs guardrails so devs don't pick colors freely |
|
||||
|
||||
All three keep the existing Svelte 5 architecture, $state cache system, and route structure unchanged. **Migration is ~3 weeks** for any one of them to land dashboard + provider list + tracker detail.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in any mockup yet
|
||||
|
||||
If a direction lands, these surfaces still need design before implementation:
|
||||
|
||||
- Tracker detail page (timeline + config editor + live preview)
|
||||
- Template editor (Jinja2 sandbox + side-by-side preview)
|
||||
- Provider list + provider detail
|
||||
- Target detail (channel inbox + delivery history)
|
||||
- Bot console (chat-style interaction log for Telegram/Matrix/Email)
|
||||
- Setup wizard (first-run experience)
|
||||
- Mobile pass — current mockups are desktop-first
|
||||
|
||||
---
|
||||
|
||||
## Original design rationale (Option A)
|
||||
|
||||
Below is the original "Bridge / Control Room" rationale, kept for reference.
|
||||
|
||||
### Direction: "Bridge / Control Room"
|
||||
|
||||
The product is literally a **signal operator's console** — it listens for events on one side (Immich, Gitea, RSS, GitHub, …) and dispatches them to channels on the other (Telegram, Matrix, Email, ntfy, …). The current UI hides that fact behind generic SaaS-dashboard chrome (teal accent, dot-grid bg, card-with-glow). The redesign leans hard into what the product *is*.
|
||||
|
||||
References that were in the room while designing this:
|
||||
|
||||
- **Bloomberg Terminal** — dense numerical clarity, monospace numerals, ticker bars
|
||||
- **Linear / Vercel** — restraint, hairline rules, type-as-interface
|
||||
- **Editorial print** (Bloomberg Businessweek, Fast Company) — italic display serif as a counterpoint to mono data
|
||||
- **Broadcast control rooms** — pulsing live indicators, "ON AIR" markers, scanline atmosphere
|
||||
- **Phosphor monitors** — the signature lime accent, not the third teal-purple SaaS template
|
||||
|
||||
---
|
||||
|
||||
## Design language
|
||||
|
||||
| Token | Choice | Why |
|
||||
| --- | --- | --- |
|
||||
| **Display** | Fraunces (variable, italic-capable serif) | Editorial gravitas; italic em-tags inside headlines feel printed, not pasted |
|
||||
| **Body** | Instrument Sans | Modern, neutral, slightly geometric — pairs well with a serif without fighting it |
|
||||
| **Data** | JetBrains Mono | Tabular numerals everywhere stats appear |
|
||||
| **Primary accent** | `#d4ff3a` phosphor lime | Distinctive — far from the SaaS teal/purple gravity well; reads as "signal" |
|
||||
| **Secondary signal** | warm coral, calm blue, amber warn, rose error | Used sparingly; one per event class |
|
||||
| **Surfaces** | Deep ink `#07080b` → `#161a25` | High-contrast console feel; light theme inverts to "broadsheet" cream |
|
||||
| **Hairlines** | 1px borders everywhere instead of shadows | Editorial precision; cards sit *in* the page, not floating over it |
|
||||
| **Scanlines + vignette** | Faint overlay | Console atmosphere without crossing into kitsch |
|
||||
|
||||
---
|
||||
|
||||
## What's actually new (UX, not just paint)
|
||||
|
||||
The mockup isn't just a re-skin — these are concrete proposed additions:
|
||||
|
||||
1. **"On Air" ticker bar** — a always-running marquee of the last 6–10 events at the very top. Pauses on hover. Keeps you peripherally aware of activity without forcing you to look at the dashboard.
|
||||
2. **Stats with sparklines** — every counter shows a 24h trend inline. Numbers without context are useless.
|
||||
3. **Editorial hero** — the title is a *sentence about the current state*, not a label. "Tonight, *everything* is flowing" with live numbers in the body. This is opinionated and might feel too much for some — easy to swap to a label-style header.
|
||||
4. **Signal stream** — replaces the existing event timeline. Adds the **routing trail** for each event (Tracker → Target → Template) so you can see at a glance where a signal went, not just *that* it happened. This is the killer feature; right now you have to click through three pages to trace one event.
|
||||
5. **"On watch" provider deck** — replaces the silent provider list with throughput-per-provider, last-seen, pulse status. Click-to-trace.
|
||||
6. **7-day pulse heatmap** — finally answers "when is this thing busiest?". Useful for planning maintenance windows.
|
||||
7. **Active wires panel** — Sankey-style "Source → Channel" route summary with throughput counts. Makes the *bridge* visible.
|
||||
8. **Compose band** — bottom of dashboard. A single CTA to start a new tracker with a 4-step wizard (provider → tracker → template → target), or paste a webhook URL and let the system infer.
|
||||
9. **Live clock + uptime** — pinned in the ticker. Operators know what time it is and how stable they've been.
|
||||
10. **Two-theme system** — Console (dark, default for most operators) + Broadsheet (light, warm cream, deep ink). Skips the generic "system theme" three-way; commits to two beautiful options instead of three mediocre ones.
|
||||
|
||||
---
|
||||
|
||||
## Things to push back on
|
||||
|
||||
These are choices I'd specifically want feedback on before implementing:
|
||||
|
||||
- **Phosphor lime as primary** — it's bold and very on-brand for "signal," but it's far from the current teal. Worth knowing if you have any brand attachment to teal.
|
||||
- **Italic Fraunces inside headlines** — distinctive, but could feel "too magazine" for a self-hosted ops tool. Easy to swap for plain Fraunces or even drop the serif entirely and lean fully on Instrument Sans + JetBrains Mono.
|
||||
- **Editorial sentence-style headers** vs. label-style headers — same trade-off as above.
|
||||
- **Hairline borders instead of cards-on-cards** — current UI uses elevated cards with glow shadows. The redesign uses flat sectioned modules with 1px rules. Read denser, less "soft."
|
||||
- **Sidebar grouping** — I collapsed the current 6-group nav into 3 sections (Overview / Routing / Operators). Some of your nested groups (notification-trackers vs command-trackers) merge into a single "Trackers" entry; click-through reveals tabs. Reduces vertical noise but loses one click of directness.
|
||||
- **No emoji / no MDI icon backgrounds** — the current UI uses lots of `mdi*` icon chips. The redesign uses thin custom SVG strokes. Cohesive but more work to maintain (would suggest a curated icon set rather than the full MDI library).
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in this mockup yet
|
||||
|
||||
If the direction lands, these are the next surfaces to design before any implementation:
|
||||
|
||||
- **Tracker detail page** — single-tracker timeline + config editor + live preview
|
||||
- **Template editor** — code-editor surface with the Jinja2 sandbox preview side-by-side
|
||||
- **Provider list / detail** — currently a grid of cards; would become a tabular operator's list
|
||||
- **Target detail** — channel inbox view with delivery history per target
|
||||
- **Bot console** — Telegram/Matrix/Email bots get a chat-style interaction log
|
||||
- **Setup wizard** — first-run experience matching the same aesthetic
|
||||
- **Mobile** — current mockup is desktop-only; the design language needs a mobile-first pass before shipping
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes (if approved)
|
||||
|
||||
- Migration is mostly a **CSS token swap** plus selective component refactors. The Svelte 5 architecture and `$state` cache system don't need to change.
|
||||
- New fonts: add `@fontsource-variable/fraunces` and `@fontsource-variable/instrument-sans`. Drop `dm-sans`.
|
||||
- Replace `app.css` `@theme` block with the new token set.
|
||||
- The ticker, sparklines, heatmap, and routes panel are all net-new components — budget those separately.
|
||||
- Custom SVG icon set: pick ~30 icons we actually use, ship them as a single sprite. Drop the runtime MDI lookup.
|
||||
|
||||
Estimate to first-shippable: **2–3 focused weeks** (one designer-pair sprint) to land dashboard + provider list + tracker detail with the new language. Rest of pages can roll over the following month without breaking the old screens.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Notify Bridge — Redesign Options</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300..800&family=JetBrains+Mono:wght@300..600&family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0b0c10;
|
||||
--surface: #14151c;
|
||||
--rule: #232531;
|
||||
--rule-strong: #353846;
|
||||
--fg: #f0eee8;
|
||||
--fg-dim: #b0b3bd;
|
||||
--mute: #6f7280;
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: 'Manrope', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
padding: 56px 32px 80px;
|
||||
background:
|
||||
radial-gradient(40vw 40vw at 18% 10%, rgba(184, 167, 255, 0.12), transparent 60%),
|
||||
radial-gradient(35vw 30vw at 88% 90%, rgba(126, 232, 196, 0.10), transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.wrap { max-width: 1240px; margin: 0 auto; }
|
||||
|
||||
.head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 56px;
|
||||
padding-bottom: 28px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.brand {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-weight: 400;
|
||||
font-size: 28px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
.brand em {
|
||||
font-style: italic;
|
||||
background: linear-gradient(135deg, #b8a7ff, #ff9ec4);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.brand small { display: block; margin-top: 6px; color: var(--mute); font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||
.meta {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--mute);
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta b { color: var(--fg); font-weight: 500; }
|
||||
|
||||
.intro {
|
||||
max-width: 720px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.intro h1 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-weight: 400;
|
||||
font-size: 56px;
|
||||
line-height: 1.0;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.intro h1 em {
|
||||
font-style: italic;
|
||||
background: linear-gradient(135deg, #c8f078, #b8a7ff);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.intro p {
|
||||
font-size: 16px;
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.6;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 980px) { .options { grid-template-columns: 1fr; } }
|
||||
|
||||
.option {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
transition: transform .25s cubic-bezier(.4,.4,0,1), border-color .25s;
|
||||
text-decoration: none; color: inherit;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.option:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--rule-strong);
|
||||
}
|
||||
.option__preview {
|
||||
height: 220px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* Option A — Bridge / Console */
|
||||
.preview--a {
|
||||
background: #07080b;
|
||||
color: #ece8df;
|
||||
}
|
||||
.preview--a::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 3px
|
||||
);
|
||||
}
|
||||
.preview--a .lime {
|
||||
position: absolute; left: 24px; top: 24px;
|
||||
background: #d4ff3a; color: #07080b;
|
||||
padding: 4px 9px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
.preview--a .num {
|
||||
position: absolute; right: 24px; top: 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 32px; color: #d4ff3a; font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.preview--a .title {
|
||||
position: absolute; left: 24px; bottom: 56px;
|
||||
font-family: 'Newsreader', serif;
|
||||
font-style: italic; font-size: 38px;
|
||||
color: #d4ff3a;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 0.95;
|
||||
}
|
||||
.preview--a .title b {
|
||||
font-style: normal; color: #ece8df; font-weight: 400;
|
||||
}
|
||||
.preview--a .rule {
|
||||
position: absolute; left: 24px; right: 24px; bottom: 36px;
|
||||
height: 1px; background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.preview--a .stream {
|
||||
position: absolute; left: 24px; bottom: 14px; right: 24px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px; color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.preview--a .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #d4ff3a; box-shadow: 0 0 6px #d4ff3a;
|
||||
}
|
||||
|
||||
/* Option B — Aurora */
|
||||
.preview--b {
|
||||
background: #050613;
|
||||
color: #f3f1ff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview--b::before {
|
||||
content: '';
|
||||
position: absolute; inset: -20%;
|
||||
background:
|
||||
radial-gradient(40% 40% at 20% 30%, rgba(184, 167, 255, 0.7), transparent 60%),
|
||||
radial-gradient(35% 35% at 80% 25%, rgba(255, 158, 196, 0.6), transparent 60%),
|
||||
radial-gradient(50% 35% at 75% 85%, rgba(126, 232, 196, 0.5), transparent 60%);
|
||||
filter: blur(40px) saturate(140%);
|
||||
}
|
||||
.preview--b .glass {
|
||||
position: absolute; left: 20px; right: 20px; top: 20px; bottom: 20px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
padding: 22px;
|
||||
}
|
||||
.preview--b .pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
font-size: 10px; color: #b8a7ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview--b .pill::before {
|
||||
content: '';
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #7ee8c4; box-shadow: 0 0 6px #7ee8c4;
|
||||
}
|
||||
.preview--b .title {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-style: italic; font-size: 34px;
|
||||
margin-top: 12px;
|
||||
background: linear-gradient(135deg, #ff9ec4, #b8a7ff 60%, #8ec9ff);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
.preview--b .title b {
|
||||
font-style: normal; color: #f3f1ff;
|
||||
background: none; -webkit-text-fill-color: #f3f1ff;
|
||||
}
|
||||
.preview--b .row {
|
||||
margin-top: 14px;
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
.preview--b .chip {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
font-size: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.preview--b .chip b { font-weight: 600; }
|
||||
|
||||
/* Option C — Bento */
|
||||
.preview--c {
|
||||
background: #f4f3ef;
|
||||
color: #0c0d11;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview--c .b-tile {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-size: 10px;
|
||||
}
|
||||
.preview--c .b-violet { background: #6d4ce6; color: white; grid-row: span 2; }
|
||||
.preview--c .b-mint { background: #c8f078; color: #1a2e0c; }
|
||||
.preview--c .b-coral { background: #ff6f5b; color: white; }
|
||||
.preview--c .b-honey { background: #ffd23a; color: #2a1f00; }
|
||||
.preview--c .b-ink { background: #0c0d11; color: white; }
|
||||
.preview--c .b-tile .lab {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
opacity: 0.7; font-weight: 500;
|
||||
}
|
||||
.preview--c .b-tile .num {
|
||||
font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.04em; line-height: 1;
|
||||
}
|
||||
.preview--c .b-violet .num { font-size: 36px; }
|
||||
.preview--c .b-tile .num small {
|
||||
font-size: 14px; opacity: 0.6;
|
||||
}
|
||||
.preview--c .b-tile .cap {
|
||||
font-size: 9px; opacity: 0.85; line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Option content */
|
||||
.option__body { padding: 24px 26px 26px; flex: 1; display: flex; flex-direction: column; }
|
||||
.option__kicker {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.option__kicker .badge {
|
||||
background: var(--rule);
|
||||
color: var(--fg);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.option__title {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-weight: 400;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.option__title em {
|
||||
font-style: italic;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.option__desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 18px;
|
||||
flex: 1;
|
||||
}
|
||||
.option__tags {
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.option__tag {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--rule);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.option__cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: var(--fg);
|
||||
font-size: 13px; font-weight: 600;
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.option__cta svg { width: 14px; height: 14px; transition: transform .2s; }
|
||||
.option:hover .option__cta svg { transform: translateX(4px); }
|
||||
|
||||
.vs {
|
||||
margin-top: 80px;
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 56px;
|
||||
}
|
||||
.vs h2 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-weight: 400;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.vs h2 em { font-style: italic; color: var(--fg-dim); }
|
||||
.vs__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vs__table th, .vs__table td {
|
||||
padding: 14px 18px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.vs__table tr:last-child td { border-bottom: 0; }
|
||||
.vs__table th {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
font-weight: 500;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.vs__table td:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.vs__table td { color: var(--fg-dim); }
|
||||
.vs__table .a { color: #d4ff3a; }
|
||||
.vs__table .b { color: #b8a7ff; }
|
||||
.vs__table .c { color: #c8f078; }
|
||||
|
||||
.foot {
|
||||
margin-top: 80px;
|
||||
text-align: center;
|
||||
color: var(--mute);
|
||||
font-size: 11.5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="head">
|
||||
<div class="brand">
|
||||
Notify <em>Bridge</em>
|
||||
<small>Redesign · 3 directions</small>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Drafted <b>Apr 25, 2026</b><br>
|
||||
For review · pick one
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="intro">
|
||||
<h1>Three directions, one <em>product</em>.</h1>
|
||||
<p>
|
||||
Each option is a real, working dashboard you can open and click around. They share the same data,
|
||||
the same product, and the same set of UX ideas — but commit to different aesthetic universes.
|
||||
Open any, then come back here to compare.
|
||||
</p>
|
||||
<p style="margin-top: 18px; padding: 12px 18px; border-left: 2px solid #b8a7ff; background: rgba(184,167,255,0.08); border-radius: 0 12px 12px 0; font-size: 14px;">
|
||||
<strong style="color:#b8a7ff">Decided · Aurora.</strong>
|
||||
Ongoing surfaces in the chosen language:
|
||||
<a href="aurora-tracker.html" style="color:#b8a7ff;font-weight:600;text-decoration:underline;text-underline-offset:3px;">Tracker detail →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="options">
|
||||
|
||||
<a class="option" href="dashboard.html">
|
||||
<div class="option__preview preview--a">
|
||||
<span class="lime">● ON AIR</span>
|
||||
<span class="num">2 814</span>
|
||||
<div class="title"><b>Tonight,</b><br>everything is <em>flowing.</em></div>
|
||||
<div class="rule"></div>
|
||||
<div class="stream">
|
||||
<span class="dot"></span><span>02:14 · IMMICH · 14 ASSETS → @FAMILY</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option__body">
|
||||
<div class="option__kicker">Option A <span class="badge">existing</span></div>
|
||||
<h3 class="option__title">Bridge <em>· Control Room</em></h3>
|
||||
<p class="option__desc">
|
||||
Editorial broadcast-console. Phosphor-lime accents on deep ink, hairline rules,
|
||||
monospace numerals, italic Fraunces serif against JetBrains Mono. Atmospheric scanlines,
|
||||
live ticker bar. Built for operators who want density and signal-room energy.
|
||||
</p>
|
||||
<div class="option__tags">
|
||||
<span class="option__tag">phosphor-lime</span>
|
||||
<span class="option__tag">Fraunces</span>
|
||||
<span class="option__tag">hairlines</span>
|
||||
<span class="option__tag">dense</span>
|
||||
</div>
|
||||
<div class="option__cta">
|
||||
Open mockup
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="option" href="dashboard-aurora.html">
|
||||
<div class="option__preview preview--b">
|
||||
<div class="glass">
|
||||
<span class="pill">Live · all systems nominal</span>
|
||||
<div class="title"><b>Tonight,</b><br><em>everything</em> flows.</div>
|
||||
<div class="row">
|
||||
<span class="chip"><b>2 814</b> sent</span>
|
||||
<span class="chip"><b>99.7%</b> ok</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option__body">
|
||||
<div class="option__kicker">Option B <span class="badge" style="background:#b8a7ff;color:#0a0a0a">new</span></div>
|
||||
<h3 class="option__title">Aurora <em>· Glass</em></h3>
|
||||
<p class="option__desc">
|
||||
Vivid aurora gradient base, frosted-glass panels, soft pastel accents — lavender, orchid,
|
||||
mint, coral. Newsreader serif headlines with gradient italics. Premium, modern, visionOS /
|
||||
Stripe-modern. Rounded, breathable, animated.
|
||||
</p>
|
||||
<div class="option__tags">
|
||||
<span class="option__tag">aurora gradient</span>
|
||||
<span class="option__tag">frosted glass</span>
|
||||
<span class="option__tag">Newsreader</span>
|
||||
<span class="option__tag">premium</span>
|
||||
</div>
|
||||
<div class="option__cta">
|
||||
Open mockup
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="option" href="dashboard-bento.html">
|
||||
<div class="option__preview preview--c">
|
||||
<div class="b-tile b-violet">
|
||||
<span class="lab">Top provider</span>
|
||||
<div>
|
||||
<div class="num">1942</div>
|
||||
<div class="cap">Immich · 8 trackers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-tile b-mint">
|
||||
<span class="lab">Trackers</span>
|
||||
<div class="num">12<small>/14</small></div>
|
||||
</div>
|
||||
<div class="b-tile b-honey">
|
||||
<span class="lab">Targets</span>
|
||||
<div class="num">19</div>
|
||||
</div>
|
||||
<div class="b-tile b-coral">
|
||||
<span class="lab">Failures</span>
|
||||
<div class="num">02</div>
|
||||
</div>
|
||||
<div class="b-tile b-ink">
|
||||
<span class="lab">Live</span>
|
||||
<div class="num" style="color:#c8f078">●</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option__body">
|
||||
<div class="option__kicker">Option C <span class="badge" style="background:#c8f078;color:#1a2e0c">new</span></div>
|
||||
<h3 class="option__title">Bento <em>· Modular</em></h3>
|
||||
<p class="option__desc">
|
||||
Mixed-size colorful tiles in a tight grid. Each module commits to one role and one bold color
|
||||
— violet, mint, coral, honey, cobalt. Manrope sans + JetBrains Mono. Apple Keynote / Linear
|
||||
blog energy. Playful but disciplined. Ships with day + night.
|
||||
</p>
|
||||
<div class="option__tags">
|
||||
<span class="option__tag">bento grid</span>
|
||||
<span class="option__tag">bold color</span>
|
||||
<span class="option__tag">Manrope</span>
|
||||
<span class="option__tag">playful</span>
|
||||
</div>
|
||||
<div class="option__cta">
|
||||
Open mockup
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="vs">
|
||||
<h2>Side <em>by side</em></h2>
|
||||
<table class="vs__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trait</th>
|
||||
<th><span class="a">A · Bridge</span></th>
|
||||
<th><span class="b">B · Aurora</span></th>
|
||||
<th><span class="c">C · Bento</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Mood</td><td>Editorial / operator</td><td>Premium / atmospheric</td><td>Playful / confident</td></tr>
|
||||
<tr><td>Default theme</td><td>Dark (Console)</td><td>Dark (Aurora)</td><td>Light (Daylight)</td></tr>
|
||||
<tr><td>Accent</td><td>Phosphor lime <code style="background:#d4ff3a;color:#07080b;padding:2px 6px;border-radius:4px;font-family:JetBrains Mono;font-size:11px">#d4ff3a</code></td><td>Lavender + orchid + mint</td><td>Violet · mint · coral · honey</td></tr>
|
||||
<tr><td>Surface</td><td>Hairline-rule modules</td><td>Frosted-glass panels</td><td>Solid-color tiles</td></tr>
|
||||
<tr><td>Display font</td><td>Fraunces (variable serif)</td><td>Newsreader (variable serif)</td><td>Manrope (geometric sans)</td></tr>
|
||||
<tr><td>Data font</td><td>JetBrains Mono</td><td>Geist Mono</td><td>JetBrains Mono</td></tr>
|
||||
<tr><td>Density</td><td>High · for power users</td><td>Medium · breathable</td><td>Medium · airy</td></tr>
|
||||
<tr><td>Risk</td><td>Niche taste · heavy mood</td><td>Trendy glass may date</td><td>Color discipline matters</td></tr>
|
||||
<tr><td>Best for</td><td>Pro operators · self-hosters</td><td>Showroom · public-facing</td><td>Mainstream · cross-audience</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="foot">Notify Bridge · v0.5.2 · drafted by Claude</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+27
-7
@@ -10,18 +10,38 @@ services:
|
||||
volumes:
|
||||
- notify-bridge-data:/data
|
||||
environment:
|
||||
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
|
||||
# Homelab target: allow outbound requests to RFC1918 / link-local addresses.
|
||||
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts,
|
||||
# which breaks tracking of Immich / Gitea / etc. running on the same LAN.
|
||||
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
# Comma-separated list of allowed browser origins. Wildcard `*` is
|
||||
# rejected on startup because credentials are enabled.
|
||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
|
||||
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
|
||||
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
|
||||
# docker bridge, or `*` only if the container is NOT reachable from the
|
||||
# public internet).
|
||||
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
|
||||
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
|
||||
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
|
||||
# enable on a publicly exposed instance.
|
||||
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
||||
# Use /api/ready (not /api/health) so the container is only reported
|
||||
# healthy after migrations and the scheduler finish booting.
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_period: 30s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
pids_limit: 256
|
||||
|
||||
volumes:
|
||||
notify-bridge-data:
|
||||
|
||||
Generated
+62
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
@@ -14,8 +14,12 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/newsreader": "^5.2.10",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"codemirror": "^6.0.2"
|
||||
},
|
||||
@@ -604,6 +608,14 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
@@ -612,6 +624,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 +648,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 +1473,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 +1596,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,
|
||||
@@ -2860,16 +2896,36 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
|
||||
},
|
||||
"@fontsource/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"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 +3431,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 +3516,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.2.6",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -34,8 +34,12 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/newsreader": "^5.2.10",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"codemirror": "^6.0.2"
|
||||
}
|
||||
|
||||
+337
-85
@@ -1,41 +1,86 @@
|
||||
@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';
|
||||
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
|
||||
so RU and EN render in the same font instead of falling back to a
|
||||
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
|
||||
(latin-only) imports — see --font-sans below for the family rename. */
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource/geist-mono/400.css';
|
||||
@import '@fontsource/geist-mono/500.css';
|
||||
@import '@fontsource/geist-mono/600.css';
|
||||
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
|
||||
declarations so Russian text renders in Geist Mono instead of falling
|
||||
back to Cascadia/Consolas. */
|
||||
@import '@fontsource/geist-mono/cyrillic-400.css';
|
||||
@import '@fontsource/geist-mono/cyrillic-500.css';
|
||||
@import '@fontsource/geist-mono/cyrillic-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 Variable', 'Geist', '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 +90,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 +149,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 +309,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 +369,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 +388,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 +412,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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// Ambient type declarations for SvelteKit + project-level build-time globals.
|
||||
|
||||
declare global {
|
||||
/** App version, injected from frontend/package.json at build time. */
|
||||
const __APP_VERSION__: string;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -5,6 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Notify Bridge</title>
|
||||
<script>
|
||||
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var resolved =
|
||||
saved === 'light' || saved === 'dark'
|
||||
? saved
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
} catch (_) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -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,25 +288,30 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/* List */
|
||||
.ep-list {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
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 +321,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 +348,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 +363,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>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { EventLog } from '$lib/types';
|
||||
import { requestHighlight } from '$lib/highlight';
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
event: EventLog | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { event, onclose }: Props = $props();
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
|
||||
if (!issuer) return '';
|
||||
if (issuer.username) return '@' + issuer.username;
|
||||
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
|
||||
if (name) return name;
|
||||
if (issuer.id) return 'id ' + issuer.id;
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Navigate to a list page and highlight the specific entity card.
|
||||
*
|
||||
* The destination page calls ``highlightFromUrl()`` after data loads,
|
||||
* which scrolls to and pulses the card with ``data-entity-id={id}``.
|
||||
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
|
||||
function openEntity(path: string, entityId: number | string | null | undefined) {
|
||||
if (entityId != null) requestHighlight(entityId);
|
||||
onclose();
|
||||
goto(path);
|
||||
}
|
||||
|
||||
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||
const issuerText = $derived(issuerLabel(issuer));
|
||||
|
||||
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
|
||||
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
|
||||
|
||||
const detailsJson = $derived.by(() => {
|
||||
if (!event?.details) return '';
|
||||
try {
|
||||
return JSON.stringify(event.details, null, 2);
|
||||
} catch {
|
||||
return String(event.details);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if event}
|
||||
<div class="event-detail">
|
||||
<!-- Subject + verb -->
|
||||
<div class="hero-row">
|
||||
<MdiIcon name="mdiBell" size={18} />
|
||||
<div>
|
||||
<div class="hero-subject">{event.collection_name || event.event_type}</div>
|
||||
<div class="hero-meta">
|
||||
<span class="event-type">{event.event_type}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDateTime(event.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provenance grid -->
|
||||
<dl class="provenance">
|
||||
{#if event.bot_name}
|
||||
<dt>{t('events.bot')}</dt>
|
||||
<dd>{event.bot_name}</dd>
|
||||
{/if}
|
||||
{#if event.collection_id && isCommand}
|
||||
<dt>{t('events.chat')}</dt>
|
||||
<dd class="font-mono">{event.collection_id}</dd>
|
||||
{/if}
|
||||
{#if issuerText}
|
||||
<dt>{t('events.issuer')}</dt>
|
||||
<dd>
|
||||
{issuerText}
|
||||
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if event.command_tracker_name}
|
||||
<dt>{t('events.commandTracker')}</dt>
|
||||
<dd>{event.command_tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.tracker_name}
|
||||
<dt>{t('events.tracker')}</dt>
|
||||
<dd>{event.tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.action_name}
|
||||
<dt>{t('events.action')}</dt>
|
||||
<dd>{event.action_name}</dd>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
<dt>{t('events.provider')}</dt>
|
||||
<dd>{event.provider_name}</dd>
|
||||
{/if}
|
||||
{#if event.assets_count > 0}
|
||||
<dt>{t('events.assetsCount')}</dt>
|
||||
<dd class="font-mono">{event.assets_count}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<!-- Action buttons — deep-link + highlight the related entity card -->
|
||||
<div class="actions">
|
||||
{#if event.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
|
||||
<MdiIcon name="mdiServer" size={14} />
|
||||
{t('events.openProvider')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.telegram_bot_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
|
||||
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||
{t('events.openBot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.command_tracker_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
|
||||
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||
{t('events.openAction')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCommand && !isAction && event.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
|
||||
<MdiIcon name="mdiRadar" size={14} />
|
||||
{t('events.openTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
|
||||
{#if detailsJson && detailsJson !== '{}'}
|
||||
<details class="raw-details" open={isCommand}>
|
||||
<summary>{t('events.rawDetails')}</summary>
|
||||
<pre>{detailsJson}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.event-detail {
|
||||
display: flex; flex-direction: column; gap: 1.1rem;
|
||||
}
|
||||
.hero-row {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
}
|
||||
.hero-subject {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.event-type {
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.dot { opacity: 0.5; }
|
||||
|
||||
.provenance {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.45rem 1rem;
|
||||
margin: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.provenance dt {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: center;
|
||||
}
|
||||
.provenance dd {
|
||||
margin: 0;
|
||||
color: var(--color-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
|
||||
|
||||
.actions {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||
}
|
||||
.actions button {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
.actions button:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.raw-details summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.raw-details summary:hover { color: var(--color-foreground); }
|
||||
.raw-details pre {
|
||||
margin: 0.55rem 0 0;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
</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,94 @@
|
||||
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;
|
||||
overscroll-behavior: contain;
|
||||
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 +271,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,159 @@
|
||||
|
||||
<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;
|
||||
overscroll-behavior: contain;
|
||||
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,48 +1,10 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
|
||||
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
|
||||
|
||||
interface LocaleMeta {
|
||||
code: string;
|
||||
name: string; // English name
|
||||
native: string; // Native script
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG: LocaleMeta[] = [
|
||||
{ code: 'en', name: 'English', native: 'English' },
|
||||
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', native: 'Français' },
|
||||
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||
];
|
||||
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
|
||||
|
||||
// Locales that ship with default notification & command templates.
|
||||
const SHIPPED = new Set(['en', 'ru']);
|
||||
@@ -76,11 +38,7 @@
|
||||
}
|
||||
|
||||
function meta(code: string): LocaleMeta {
|
||||
return CATALOG.find(l => l.code === code) ?? {
|
||||
code,
|
||||
name: code.toUpperCase(),
|
||||
native: code.toUpperCase(),
|
||||
};
|
||||
return getLocaleMeta(code);
|
||||
}
|
||||
|
||||
function remove(code: string) {
|
||||
@@ -109,79 +67,48 @@
|
||||
|
||||
// --- Add flow ----------------------------------------------------------
|
||||
|
||||
let addOpen = $state(false);
|
||||
let addQuery = $state('');
|
||||
let addInputEl = $state<HTMLInputElement | null>(null);
|
||||
let highlightIdx = $state(0);
|
||||
|
||||
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||
|
||||
const selectedSet = $derived(new Set(codes));
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||
if (!q) return available;
|
||||
return available.filter(l =>
|
||||
l.code.includes(q)
|
||||
|| l.name.toLowerCase().includes(q)
|
||||
|| l.native.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Catalog languages not yet selected, surfaced through EntitySelect.
|
||||
* Native name is the label so the user sees their own script; the
|
||||
* English name + code lives in the description for searchability.
|
||||
*/
|
||||
const addItems = $derived<EntityItem[]>(
|
||||
CATALOG
|
||||
.filter(l => !selectedSet.has(l.code))
|
||||
.map(l => ({
|
||||
value: l.code,
|
||||
label: l.native,
|
||||
desc: `${l.name} · ${l.code.toUpperCase()}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const canAddCustom = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
if (!q) return false;
|
||||
if (!CUSTOM_RE.test(q)) return false;
|
||||
if (selectedSet.has(q)) return false;
|
||||
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||
if (CATALOG.some(l => l.code === q)) return false;
|
||||
let customCode = $state('');
|
||||
const customCodeValid = $derived.by(() => {
|
||||
const c = customCode.trim().toLowerCase();
|
||||
if (!c || !CUSTOM_RE.test(c)) return false;
|
||||
if (selectedSet.has(c)) return false;
|
||||
if (CATALOG.some(l => l.code === c)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
addOpen = true;
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
addOpen = false;
|
||||
addQuery = '';
|
||||
}
|
||||
|
||||
function addCode(code: string) {
|
||||
const c = code.trim().toLowerCase();
|
||||
function addCode(code: string | number | null) {
|
||||
if (code === null) return;
|
||||
const c = String(code).trim().toLowerCase();
|
||||
if (!c) return;
|
||||
commit([...codes, c]);
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function onAddKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closeAdd(); return; }
|
||||
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightIdx < suggestions.length) {
|
||||
addCode(suggestions[highlightIdx].code);
|
||||
} else if (canAddCustom) {
|
||||
addCode(addQuery);
|
||||
}
|
||||
}
|
||||
function addCustom() {
|
||||
if (!customCodeValid) return;
|
||||
addCode(customCode);
|
||||
customCode = '';
|
||||
}
|
||||
|
||||
$effect(() => { addQuery; highlightIdx = 0; });
|
||||
|
||||
// --- Drag & drop -------------------------------------------------------
|
||||
|
||||
let dragCode = $state<string | null>(null);
|
||||
@@ -329,77 +256,39 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Add zone -->
|
||||
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||
{#if !addOpen}
|
||||
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
<span>{t('locales.add')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="ls-add-panel">
|
||||
<div class="ls-add-input-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={addInputEl}
|
||||
bind:value={addQuery}
|
||||
onkeydown={onAddKeydown}
|
||||
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||
placeholder={t('locales.searchPlaceholder')}
|
||||
class="ls-add-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ls-add-list" role="listbox">
|
||||
{#each suggestions as s, i (s.code)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === highlightIdx}
|
||||
class="ls-sugg"
|
||||
class:ls-sugg-hl={i === highlightIdx}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||
>
|
||||
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||
<span class="ls-sugg-name">{s.name}</span>
|
||||
<span class="ls-sugg-code">{s.code}</span>
|
||||
{#if SHIPPED.has(s.code)}
|
||||
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if canAddCustom}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightIdx === suggestions.length}
|
||||
class="ls-sugg ls-sugg-custom"
|
||||
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||
onmouseenter={() => highlightIdx = suggestions.length}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||
>
|
||||
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length === 0 && !canAddCustom}
|
||||
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
|
||||
<div class="ls-add">
|
||||
<div class="ls-add-row">
|
||||
<div class="ls-add-picker">
|
||||
<EntitySelect
|
||||
items={addItems}
|
||||
value={null}
|
||||
placeholder={t('locales.add')}
|
||||
size="sm"
|
||||
onselect={addCode}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ls-add-custom">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={customCode}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
|
||||
placeholder={t('locales.customPlaceholder')}
|
||||
class="ls-add-custom-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-add-custom-btn"
|
||||
disabled={!customCodeValid}
|
||||
onclick={addCustom}
|
||||
title={t('locales.addCustom')}
|
||||
>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ls-hint">
|
||||
@@ -630,125 +519,60 @@
|
||||
.ls-add {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.ls-add-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.ls-add-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
border-style: solid;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.ls-add-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
overflow: hidden;
|
||||
animation: ls-pop 0.15s ease-out;
|
||||
}
|
||||
@keyframes ls-pop {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.ls-add-input-row {
|
||||
.ls-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-add-input {
|
||||
.ls-add-picker {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.ls-add-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
.ls-add-custom-input {
|
||||
width: 6rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ls-add-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.ls-sugg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.ls-sugg-native {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.ls-add-custom-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
.ls-sugg-shipped {
|
||||
.ls-add-custom-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ls-sugg-custom {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-sugg-custom-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-sugg-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ls-add-custom-btn:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-add-custom-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Hint --------------------------------------------------------- */
|
||||
|
||||
@@ -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,144 @@
|
||||
<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;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -294,6 +307,7 @@
|
||||
|
||||
.mes-list {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
@@ -319,7 +333,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,176 @@
|
||||
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;
|
||||
overscroll-behavior: contain;
|
||||
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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let {
|
||||
value = $bindable<string>('UTC'),
|
||||
@@ -172,18 +173,12 @@
|
||||
|
||||
$effect(() => { query; highlightIdx = 0; });
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node;
|
||||
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
});
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
});
|
||||
/**
|
||||
* The panel is portalled to <body> to escape Card's overflow:hidden +
|
||||
* backdrop-filter (which would otherwise clip and stacking-trap the
|
||||
* dropdown). Outside-click is detected via the dedicated overlay div
|
||||
* rather than a document listener, so we don't need a global handler.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div class="tz-root">
|
||||
@@ -217,83 +212,87 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
<div use:portal class="tz-portal-root">
|
||||
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
|
||||
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -408,35 +407,66 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Panel -------------------------------------------------------- */
|
||||
.tz-panel {
|
||||
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
|
||||
.tz-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tz-overlay {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
|
||||
/* ---- Panel (centered modal palette) -------------------------------- */
|
||||
.tz-panel {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: min(20vh, 120px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
width: min(540px, 92vw);
|
||||
max-height: min(60vh, 30rem);
|
||||
background: var(--tz-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong, var(--color-border));
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
|
||||
0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 26rem;
|
||||
animation: tz-pop 0.15s ease-out;
|
||||
--tz-solid-bg: #131520;
|
||||
}
|
||||
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
|
||||
.tz-panel::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
@keyframes tz-pop {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from { opacity: 0; transform: translate(-50%, -3px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
.tz-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-search {
|
||||
flex: 1;
|
||||
@@ -464,6 +494,8 @@
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-quick-btn {
|
||||
display: inline-flex;
|
||||
@@ -498,8 +530,14 @@
|
||||
|
||||
.tz-list {
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
overscroll-behavior: contain;
|
||||
/* No top padding — the sticky group head is at top:0 of the
|
||||
scroll container, so any padding-top would let scrolling
|
||||
items leak into the gap above the sticky header. */
|
||||
padding: 0 0 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-empty {
|
||||
padding: 1rem;
|
||||
@@ -523,7 +561,7 @@
|
||||
color: var(--color-muted-foreground);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-card, var(--color-background));
|
||||
background: var(--tz-solid-bg);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
|
||||
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
|
||||
];
|
||||
|
||||
// --- Log level ---
|
||||
|
||||
export const logLevelItems = (): GridItem[] => [
|
||||
{ value: 'DEBUG', icon: 'mdiBugOutline', label: 'DEBUG', desc: t('gridDesc.logLevelDebug') },
|
||||
{ value: 'INFO', icon: 'mdiInformationOutline', label: 'INFO', desc: t('gridDesc.logLevelInfo') },
|
||||
{ value: 'WARNING', icon: 'mdiAlertOutline', label: 'WARNING', desc: t('gridDesc.logLevelWarning') },
|
||||
{ value: 'ERROR', icon: 'mdiAlertOctagonOutline', label: 'ERROR', desc: t('gridDesc.logLevelError') },
|
||||
];
|
||||
|
||||
// --- Log format ---
|
||||
|
||||
export const logFormatItems = (): GridItem[] => [
|
||||
{ value: 'text', icon: 'mdiFormatText', label: 'text', desc: t('gridDesc.logFormatText') },
|
||||
{ value: 'json', icon: 'mdiCodeJson', label: 'json', desc: t('gridDesc.logFormatJson') },
|
||||
];
|
||||
|
||||
// --- Response mode ---
|
||||
|
||||
export const responseModeItems = (tFn: typeof t): GridItem[] => [
|
||||
@@ -92,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
|
||||
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
|
||||
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
|
||||
];
|
||||
|
||||
// --- Sort filter (dashboard) ---
|
||||
@@ -101,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
||||
];
|
||||
|
||||
// --- Auto-refresh interval (dashboard events list) ---
|
||||
//
|
||||
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||
// in routes/+page.svelte if you add or remove cadences.
|
||||
|
||||
export const refreshIntervalItems = (): GridItem[] => [
|
||||
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
|
||||
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
|
||||
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
|
||||
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
|
||||
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
|
||||
];
|
||||
|
||||
// --- Chat action (Telegram targets) ---
|
||||
|
||||
export const chatActionItems = (): GridItem[] => [
|
||||
|
||||
+188
-13
@@ -3,7 +3,22 @@
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Service notifications"
|
||||
},
|
||||
"crumbs": {
|
||||
"routingNotification": "Routing · Notification",
|
||||
"routingCommands": "Routing · Commands",
|
||||
"routingTargets": "Routing · Targets",
|
||||
"routingAutomation": "Routing · Automation",
|
||||
"operatorsBots": "Operators · Bots",
|
||||
"systemAccess": "System · Access",
|
||||
"systemConfiguration": "System · Configuration",
|
||||
"systemMaintenance": "System · Maintenance",
|
||||
"serviceConnections": "Service · Connections"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Overview",
|
||||
"sectionRouting": "Routing",
|
||||
"sectionOperators": "Operators",
|
||||
"sectionSystem": "System",
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"notificationTrackers": "Notif. Trackers",
|
||||
@@ -55,7 +70,8 @@
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"or": "or",
|
||||
"loginFailed": "Login failed",
|
||||
"setupFailed": "Setup failed"
|
||||
"setupFailed": "Setup failed",
|
||||
"backendUnreachable": "Cannot reach the server. Check that it's running and try again."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -78,9 +94,19 @@
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed",
|
||||
"scheduledMessage": "scheduled message",
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
"commandHandled": "command handled",
|
||||
"commandRateLimited": "rate limited",
|
||||
"commandFailed": "command failed",
|
||||
"autoRefreshTitle": "Auto-refresh interval for the events list",
|
||||
"refreshOff": "Off",
|
||||
"refresh10s": "10s",
|
||||
"refresh30s": "30s",
|
||||
"refresh60s": "1m",
|
||||
"refresh5m": "5m",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
@@ -91,6 +117,9 @@
|
||||
"filterActionSuccess": "Action Success",
|
||||
"filterActionPartial": "Action Partial",
|
||||
"filterActionFailed": "Action Failed",
|
||||
"filterCommandHandled": "Command Handled",
|
||||
"filterCommandRateLimited": "Rate Limited",
|
||||
"filterCommandFailed": "Command Failed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
@@ -101,11 +130,63 @@
|
||||
"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"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Event details",
|
||||
"bot": "Bot",
|
||||
"chat": "Chat",
|
||||
"issuer": "Issued by",
|
||||
"commandTracker": "Command tracker",
|
||||
"tracker": "Tracker",
|
||||
"action": "Action",
|
||||
"provider": "Provider",
|
||||
"assetsCount": "Assets",
|
||||
"openProvider": "Open provider",
|
||||
"openBot": "Open bot",
|
||||
"openCommandTracker": "Open command tracker",
|
||||
"openAction": "Open action",
|
||||
"openTracker": "Open tracker",
|
||||
"rawDetails": "Raw details"
|
||||
},
|
||||
"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",
|
||||
@@ -151,7 +232,8 @@
|
||||
"apiToken": "API Token",
|
||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
|
||||
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings. The full URL is shown when an external base URL is configured in Settings; otherwise it is relative to your bridge host.",
|
||||
"webhookUrlCopyTitle": "Click to copy",
|
||||
"nutHost": "NUT Server Host",
|
||||
"nutHostPlaceholder": "192.168.1.100 or ups.local",
|
||||
"nutPort": "NUT Server Port",
|
||||
@@ -190,7 +272,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",
|
||||
@@ -202,6 +287,9 @@
|
||||
"selectAlbums": "Select albums...",
|
||||
"repositories": "Repositories",
|
||||
"selectRepositories": "Select repositories...",
|
||||
"userAllowlist": "Only from users",
|
||||
"userBlocklist": "Exclude users",
|
||||
"selectUsers": "Pick users...",
|
||||
"boards": "Boards",
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
@@ -248,7 +336,8 @@
|
||||
"descending": "Descending",
|
||||
"quietHoursStart": "Quiet hours start",
|
||||
"quietHoursEnd": "Quiet hours end",
|
||||
"batchDuration": "Batch duration (seconds)",
|
||||
"adaptiveMaxSkip": "Adaptive polling cap",
|
||||
"adaptiveMaxSkipPlaceholder": "Off (blank or 0)",
|
||||
"defaultTrackingConfig": "Default tracking config",
|
||||
"defaultTemplateConfig": "Default template config",
|
||||
"linkedTargets": "targets",
|
||||
@@ -260,7 +349,15 @@
|
||||
"testPeriodic": "Test periodic summary",
|
||||
"testScheduled": "Test scheduled assets",
|
||||
"testMemory": "Test memory / On This Day",
|
||||
"testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.",
|
||||
"checkingLinks": "Checking links...",
|
||||
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
|
||||
"openTrackingConfig": "Open Tracking Config",
|
||||
"openTemplateConfig": "Open Template Config",
|
||||
"linkReplace": "Replace",
|
||||
"linkReplacing": "Replacing...",
|
||||
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
|
||||
"linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.",
|
||||
"missingLinksTitle": "Albums Missing Public Links",
|
||||
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
|
||||
"expired": "Expired",
|
||||
@@ -294,6 +391,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",
|
||||
@@ -362,6 +464,8 @@
|
||||
"receiverDisabled": "Receiver disabled"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "& access",
|
||||
"countLabel": "users",
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"addUser": "Add User",
|
||||
@@ -379,6 +483,8 @@
|
||||
"noUsers": "No users found"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "bots",
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
@@ -411,6 +517,7 @@
|
||||
"noCommandsForProvider": "This provider type does not support bot commands.",
|
||||
"syncCommands": "Sync Commands",
|
||||
"discoverChats": "Discover chats from Telegram",
|
||||
"discoveringChats": "Discovering chats…",
|
||||
"clickToCopy": "Click to copy chat ID",
|
||||
"chatsDiscovered": "Chats discovered",
|
||||
"chatDeleted": "Chat removed",
|
||||
@@ -431,6 +538,8 @@
|
||||
"webhookRegistered": "Webhook registered",
|
||||
"webhookUnregistered": "Webhook unregistered",
|
||||
"updateMode": "Update mode",
|
||||
"none": "None",
|
||||
"noneActive": "Listener disabled",
|
||||
"polling": "Polling",
|
||||
"webhook": "Webhook",
|
||||
"webhookStatus": "Webhook status",
|
||||
@@ -453,6 +562,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",
|
||||
@@ -548,11 +659,21 @@
|
||||
"renamed": "renamed",
|
||||
"deleted": "deleted",
|
||||
"providerType": "Provider Type",
|
||||
"sortRandom": "Random"
|
||||
"sortRandom": "Random",
|
||||
"timesInlineHelp": "HH:MM, comma-separated",
|
||||
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
|
||||
"previewTemplate": "Preview template",
|
||||
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
|
||||
"editTemplate": "Edit template",
|
||||
"quietHoursZero": "Quiet period is 0 minutes — adjust times",
|
||||
"nextDay": "next day"
|
||||
},
|
||||
"templateConfig": {
|
||||
"titleEmphasis": "templates",
|
||||
"countLabel": "templates",
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"language": "Language",
|
||||
"providerType": "Service Provider Type",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
@@ -594,7 +715,14 @@
|
||||
"confirmDelete": "Delete this template config?",
|
||||
"invalidFormat": "Invalid format string",
|
||||
"filterSlots": "Filter slots...",
|
||||
"slots": "slots"
|
||||
"slots": "slots",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllToDefaults": "Reset all to defaults",
|
||||
"resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.",
|
||||
"resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.",
|
||||
"resetNoDefault": "No shipped default for this slot.",
|
||||
"resetApplied": "Reset to default (not saved yet — click Save to persist)",
|
||||
"deepLinkNoConfig": "No template config found for this provider. Create one first."
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": {
|
||||
@@ -663,6 +791,7 @@
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "options",
|
||||
"title": "Settings",
|
||||
"description": "Global application settings",
|
||||
"general": "General",
|
||||
@@ -694,6 +823,13 @@
|
||||
"locales": "Template Languages",
|
||||
"supportedLocales": "Supported Locales",
|
||||
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
|
||||
"logging": "Logging",
|
||||
"logLevel": "Log Level",
|
||||
"logLevelHint": "Root log level for the server. Raise to DEBUG while investigating; keep at INFO in production. WARNING/ERROR hide per-command progress lines.",
|
||||
"logFormat": "Log Format",
|
||||
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
||||
"logLevels": "Per-Module Overrides",
|
||||
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||
"saved": "Settings saved"
|
||||
},
|
||||
"hints": {
|
||||
@@ -704,9 +840,12 @@
|
||||
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
"periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
|
||||
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
|
||||
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
||||
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
|
||||
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
|
||||
"minRating": "Only include assets with at least this star rating (0 = no filter).",
|
||||
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
|
||||
"assetFormatting": "How individual assets are formatted within notification messages.",
|
||||
@@ -720,15 +859,21 @@
|
||||
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
|
||||
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
||||
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
||||
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.",
|
||||
"adaptiveMaxSkip": "Reduces polling when the tracker is idle, to save load on the upstream server. Leave blank or set to 0 for snappy notifications — every tick runs at the configured interval. Set to 2 to allow up to 2× slower polling after ~5 min of silence, or 4 for up to 4× slower polling after ~15 min. Activity resets back to the base rate immediately.",
|
||||
"defaultTrackingConfig": "Applied to all linked targets unless overridden per target.",
|
||||
"defaultTemplateConfig": "Applied to all linked targets unless overridden per target.",
|
||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
|
||||
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
|
||||
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
|
||||
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
|
||||
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
"countLabel": "bots",
|
||||
"title": "Matrix Bots",
|
||||
"description": "Matrix homeserver connections for room notifications",
|
||||
"addBot": "Add Matrix Bot",
|
||||
@@ -745,6 +890,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "accounts",
|
||||
"title": "Email Bots",
|
||||
"description": "SMTP email senders for notifications",
|
||||
"addBot": "Add Email Bot",
|
||||
@@ -764,6 +911,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "templates",
|
||||
"countLabel": "templates",
|
||||
"title": "Command Templates",
|
||||
"description": "Customize command response messages with Jinja2 templates",
|
||||
"newConfig": "New Config",
|
||||
@@ -773,10 +922,15 @@
|
||||
"noConfigs": "No command template configs yet.",
|
||||
"confirmDelete": "Delete this command template config?",
|
||||
"commandResponses": "Command Responses",
|
||||
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
|
||||
"commandErrors": "Error Messages",
|
||||
"commandDescriptions": "Command Descriptions",
|
||||
"commandUsage": "Usage Examples"
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "configs",
|
||||
"countLabel": "configs",
|
||||
"title": "Command Configs",
|
||||
"noCommandsForProvider": "No commands available for this provider type.",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
@@ -798,6 +952,7 @@
|
||||
"noTemplate": "Default (hardcoded)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "trackers",
|
||||
"title": "Command Trackers",
|
||||
"description": "Manage command trackers and their listeners",
|
||||
"newTracker": "New Tracker",
|
||||
@@ -839,6 +994,7 @@
|
||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||
"add": "Add language",
|
||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||
"customPlaceholder": "or de-CH",
|
||||
"addCustom": "Add custom code",
|
||||
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||
"primary": "Primary",
|
||||
@@ -911,6 +1067,8 @@
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
@@ -1018,6 +1176,12 @@
|
||||
"memorySourceNative": "Use Immich native memories API",
|
||||
"localeEn": "English interface",
|
||||
"localeRu": "Russian interface",
|
||||
"logLevelDebug": "Verbose — show every step",
|
||||
"logLevelInfo": "Default — high-level events",
|
||||
"logLevelWarning": "Warnings and errors only",
|
||||
"logLevelError": "Errors only — quietest",
|
||||
"logFormatText": "Human-readable plain text",
|
||||
"logFormatJson": "One JSON object per line",
|
||||
"modeMedia": "Send actual photo/video files",
|
||||
"modeText": "Send file names and links only",
|
||||
"allEvents": "Show all event types",
|
||||
@@ -1029,6 +1193,14 @@
|
||||
"actionSuccess": "Scheduled action completed",
|
||||
"actionPartial": "Scheduled action partially succeeded",
|
||||
"actionFailed": "Scheduled action failed",
|
||||
"commandHandled": "Bot command served",
|
||||
"commandRateLimited": "Bot command throttled",
|
||||
"commandFailed": "Bot command crashed",
|
||||
"refreshOff": "Auto-refresh disabled",
|
||||
"refresh10s": "Refresh every 10 seconds",
|
||||
"refresh30s": "Refresh every 30 seconds",
|
||||
"refresh60s": "Refresh every minute",
|
||||
"refresh5m": "Refresh every 5 minutes",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
@@ -1080,6 +1252,8 @@
|
||||
"close": "close"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "automations",
|
||||
"countLabel": "actions",
|
||||
"title": "Actions",
|
||||
"description": "Scheduled mutations on external services",
|
||||
"addAction": "Add Action",
|
||||
@@ -1137,6 +1311,7 @@
|
||||
"triggerScheduled": "scheduled"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "& restore",
|
||||
"title": "Backup & Restore",
|
||||
"description": "Export and import your configuration, or set up automatic backups",
|
||||
"export": "Export Configuration",
|
||||
|
||||
+187
-12
@@ -3,7 +3,22 @@
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Уведомления о сервисах"
|
||||
},
|
||||
"crumbs": {
|
||||
"routingNotification": "Маршрутизация · Уведомления",
|
||||
"routingCommands": "Маршрутизация · Команды",
|
||||
"routingTargets": "Маршрутизация · Цели",
|
||||
"routingAutomation": "Маршрутизация · Автоматизация",
|
||||
"operatorsBots": "Операторы · Боты",
|
||||
"systemAccess": "Система · Доступ",
|
||||
"systemConfiguration": "Система · Настройки",
|
||||
"systemMaintenance": "Система · Обслуживание",
|
||||
"serviceConnections": "Сервис · Подключения"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Обзор",
|
||||
"sectionRouting": "Маршрутизация",
|
||||
"sectionOperators": "Операторы",
|
||||
"sectionSystem": "Система",
|
||||
"dashboard": "Главная",
|
||||
"providers": "Провайдеры",
|
||||
"notificationTrackers": "Трекеры увед.",
|
||||
@@ -55,7 +70,8 @@
|
||||
"passwordTooShort": "Пароль должен быть не менее 8 символов",
|
||||
"or": "или",
|
||||
"loginFailed": "Ошибка входа",
|
||||
"setupFailed": "Ошибка настройки"
|
||||
"setupFailed": "Ошибка настройки",
|
||||
"backendUnreachable": "Не удалось подключиться к серверу. Убедитесь, что он запущен, и повторите попытку."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Главная",
|
||||
@@ -78,9 +94,19 @@
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа",
|
||||
"scheduledMessage": "запланированное сообщение",
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"commandHandled": "команда обработана",
|
||||
"commandRateLimited": "ограничение частоты",
|
||||
"commandFailed": "команда упала",
|
||||
"autoRefreshTitle": "Интервал авто-обновления списка событий",
|
||||
"refreshOff": "Выкл",
|
||||
"refresh10s": "10с",
|
||||
"refresh30s": "30с",
|
||||
"refresh60s": "1м",
|
||||
"refresh5m": "5м",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -91,6 +117,9 @@
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"filterCommandHandled": "Команда обработана",
|
||||
"filterCommandRateLimited": "Ограничение частоты",
|
||||
"filterCommandFailed": "Команда упала",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -101,11 +130,63 @@
|
||||
"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": "Событий"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Детали события",
|
||||
"bot": "Бот",
|
||||
"chat": "Чат",
|
||||
"issuer": "Отправитель",
|
||||
"commandTracker": "Командный трекер",
|
||||
"tracker": "Трекер",
|
||||
"action": "Действие",
|
||||
"provider": "Провайдер",
|
||||
"assetsCount": "Файлов",
|
||||
"openProvider": "Открыть провайдера",
|
||||
"openBot": "Открыть бота",
|
||||
"openCommandTracker": "Открыть командный трекер",
|
||||
"openAction": "Открыть действие",
|
||||
"openTracker": "Открыть трекер",
|
||||
"rawDetails": "Сырые данные"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Провайдеры",
|
||||
"description": "Управление подключениями к сервисам",
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
"description": "Подключения к внешним сервисам и вебхукам. Каждый провайдер кормит трекеры событиями, которые рассылаются по вашим каналам.",
|
||||
"typeSingular": "тип",
|
||||
"typePlural": "типов",
|
||||
"addProvider": "Добавить провайдер",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип провайдера",
|
||||
@@ -151,7 +232,8 @@
|
||||
"apiToken": "API токен",
|
||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||
"webhookUrl": "URL вебхука",
|
||||
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
|
||||
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
|
||||
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
|
||||
"nutHost": "Хост NUT-сервера",
|
||||
"nutHostPlaceholder": "192.168.1.100 или ups.local",
|
||||
"nutPort": "Порт NUT-сервера",
|
||||
@@ -190,6 +272,9 @@
|
||||
"cleared": "История запросов очищена"
|
||||
},
|
||||
"notificationTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"armed": "активны",
|
||||
"paused": "на паузе",
|
||||
"title": "Трекеры уведомлений",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -202,6 +287,9 @@
|
||||
"selectAlbums": "Выберите альбомы...",
|
||||
"repositories": "Репозитории",
|
||||
"selectRepositories": "Выберите репозитории...",
|
||||
"userAllowlist": "Только от пользователей",
|
||||
"userBlocklist": "Исключить пользователей",
|
||||
"selectUsers": "Выберите пользователей...",
|
||||
"boards": "Доски",
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
@@ -248,7 +336,8 @@
|
||||
"descending": "По убыванию",
|
||||
"quietHoursStart": "Тихие часы начало",
|
||||
"quietHoursEnd": "Тихие часы конец",
|
||||
"batchDuration": "Длительность пакета (секунды)",
|
||||
"adaptiveMaxSkip": "Предел адаптивного опроса",
|
||||
"adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)",
|
||||
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
||||
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
||||
"linkedTargets": "получатели",
|
||||
@@ -260,7 +349,15 @@
|
||||
"testPeriodic": "Тест периодической сводки",
|
||||
"testScheduled": "Тест запланированных фото",
|
||||
"testMemory": "Тест воспоминаний",
|
||||
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
|
||||
"checkingLinks": "Проверка ссылок...",
|
||||
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
||||
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
||||
"openTemplateConfig": "Открыть конфигурацию шаблона",
|
||||
"linkReplace": "Пересоздать",
|
||||
"linkReplacing": "Пересоздание...",
|
||||
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
||||
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
|
||||
"missingLinksTitle": "Альбомы без публичных ссылок",
|
||||
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
|
||||
"expired": "Истёк",
|
||||
@@ -294,6 +391,11 @@
|
||||
"albumDeleted": "Альбом удалён"
|
||||
},
|
||||
"targets": {
|
||||
"titleEmphasis": "канал",
|
||||
"titleEmphasisAll": "каналы",
|
||||
"receiver": "получатель",
|
||||
"receivers": "получателей",
|
||||
"channelsCount": "каналов",
|
||||
"title": "Получатели",
|
||||
"description": "Адреса доставки уведомлений",
|
||||
"descTelegram": "Чаты Telegram для доставки уведомлений",
|
||||
@@ -362,6 +464,8 @@
|
||||
"receiverDisabled": "Получатель отключён"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "и доступ",
|
||||
"countLabel": "пользователей",
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"addUser": "Добавить пользователя",
|
||||
@@ -379,6 +483,8 @@
|
||||
"noUsers": "Пользователи не найдены"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "ботов",
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
@@ -411,6 +517,7 @@
|
||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||
"syncCommands": "Синхр. команды",
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"discoveringChats": "Поиск чатов…",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён",
|
||||
@@ -431,6 +538,8 @@
|
||||
"webhookRegistered": "Вебхук зарегистрирован",
|
||||
"webhookUnregistered": "Вебхук удалён",
|
||||
"updateMode": "Режим обновлений",
|
||||
"none": "Откл.",
|
||||
"noneActive": "Приём обновлений отключён",
|
||||
"polling": "Опрос",
|
||||
"webhook": "Вебхук",
|
||||
"webhookStatus": "Статус вебхука",
|
||||
@@ -453,6 +562,8 @@
|
||||
"webhookFailed": "Не удалось зарегистрировать webhook"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
@@ -548,11 +659,21 @@
|
||||
"renamed": "переименование",
|
||||
"deleted": "удалён",
|
||||
"providerType": "Тип провайдера",
|
||||
"sortRandom": "Случайный"
|
||||
"sortRandom": "Случайный",
|
||||
"timesInlineHelp": "ЧЧ:ММ, через запятую",
|
||||
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
|
||||
"previewTemplate": "Предпросмотр шаблона",
|
||||
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
|
||||
"editTemplate": "Редактировать шаблон",
|
||||
"quietHoursZero": "Тихий период 0 минут — скорректируйте время",
|
||||
"nextDay": "след. день"
|
||||
},
|
||||
"templateConfig": {
|
||||
"titleEmphasis": "шаблоны",
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"language": "Язык",
|
||||
"providerType": "Тип сервис-провайдера",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -594,7 +715,14 @@
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||
"invalidFormat": "Некорректная строка формата",
|
||||
"filterSlots": "Фильтр слотов...",
|
||||
"slots": "слотов"
|
||||
"slots": "слотов",
|
||||
"resetToDefault": "Сбросить к умолчанию",
|
||||
"resetAllToDefaults": "Сбросить все к умолчаниям",
|
||||
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
|
||||
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
|
||||
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
|
||||
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
|
||||
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": {
|
||||
@@ -663,6 +791,7 @@
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "параметры",
|
||||
"title": "Настройки",
|
||||
"description": "Глобальные настройки приложения",
|
||||
"general": "Общие",
|
||||
@@ -694,6 +823,13 @@
|
||||
"locales": "Языки шаблонов",
|
||||
"supportedLocales": "Поддерживаемые локали",
|
||||
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
|
||||
"logging": "Логирование",
|
||||
"logLevel": "Уровень логов",
|
||||
"logLevelHint": "Уровень логирования сервера. Поднимайте до DEBUG при отладке; оставляйте INFO в продакшене. WARNING/ERROR скрывают пошаговые строки по командам.",
|
||||
"logFormat": "Формат логов",
|
||||
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
||||
"logLevels": "Переопределения по модулям",
|
||||
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||
"saved": "Настройки сохранены"
|
||||
},
|
||||
"hints": {
|
||||
@@ -704,9 +840,12 @@
|
||||
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
||||
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
||||
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
||||
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
||||
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||
@@ -720,15 +859,21 @@
|
||||
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
|
||||
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
||||
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
||||
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.",
|
||||
"adaptiveMaxSkip": "Снижает частоту опроса, когда отслеживание простаивает — уменьшает нагрузку на сервер-источник. Оставьте пустым или 0, чтобы уведомления приходили без задержки: каждый тик выполняется с заданным интервалом. Значение 2 позволит замедлять опрос до 2× после ~5 мин простоя, а 4 — до 4× после ~15 мин. Любая активность сразу возвращает базовую частоту.",
|
||||
"defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||
"defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
|
||||
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
|
||||
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
|
||||
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
|
||||
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
"countLabel": "ботов",
|
||||
"title": "Matrix боты",
|
||||
"description": "Подключения к Matrix серверам для уведомлений в комнаты",
|
||||
"addBot": "Добавить Matrix бот",
|
||||
@@ -745,6 +890,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "учётных записей",
|
||||
"title": "Email боты",
|
||||
"description": "SMTP отправители для уведомлений по email",
|
||||
"addBot": "Добавить Email бот",
|
||||
@@ -764,6 +911,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "шаблоны",
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Шаблоны команд",
|
||||
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
||||
"newConfig": "Новый шаблон",
|
||||
@@ -773,10 +922,15 @@
|
||||
"noConfigs": "Шаблонов команд пока нет.",
|
||||
"confirmDelete": "Удалить этот шаблон команд?",
|
||||
"commandResponses": "Ответы команд",
|
||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||
"commandErrors": "Сообщения об ошибках",
|
||||
"commandDescriptions": "Описания команд",
|
||||
"commandUsage": "Примеры использования"
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -798,6 +952,7 @@
|
||||
"noTemplate": "По умолчанию (встроенный)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"title": "Трекеры команд",
|
||||
"description": "Управление трекерами команд и их слушателями",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -839,6 +994,7 @@
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
"customPlaceholder": "или de-CH",
|
||||
"addCustom": "Добавить свой код",
|
||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||
"primary": "Основной",
|
||||
@@ -911,6 +1067,8 @@
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"hide": "Скрыть",
|
||||
"show": "Показать",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
@@ -1018,6 +1176,12 @@
|
||||
"memorySourceNative": "Использовать API воспоминаний Immich",
|
||||
"localeEn": "Английский интерфейс",
|
||||
"localeRu": "Русский интерфейс",
|
||||
"logLevelDebug": "Подробный — каждый шаг",
|
||||
"logLevelInfo": "По умолчанию — ключевые события",
|
||||
"logLevelWarning": "Только предупреждения и ошибки",
|
||||
"logLevelError": "Только ошибки — самый тихий",
|
||||
"logFormatText": "Читаемый человеком текст",
|
||||
"logFormatJson": "Один JSON-объект на строку",
|
||||
"modeMedia": "Отправка файлов фото/видео",
|
||||
"modeText": "Только имена файлов и ссылки",
|
||||
"allEvents": "Показать все типы событий",
|
||||
@@ -1029,6 +1193,14 @@
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"commandHandled": "Команда бота обработана",
|
||||
"commandRateLimited": "Команда бота ограничена по частоте",
|
||||
"commandFailed": "Команда бота вызвала ошибку",
|
||||
"refreshOff": "Автообновление выключено",
|
||||
"refresh10s": "Обновлять каждые 10 секунд",
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1080,6 +1252,8 @@
|
||||
"close": "закрыть"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "автоматизации",
|
||||
"countLabel": "действий",
|
||||
"title": "Действия",
|
||||
"description": "Запланированные операции над внешними сервисами",
|
||||
"addAction": "Добавить действие",
|
||||
@@ -1137,6 +1311,7 @@
|
||||
"triggerScheduled": "по расписанию"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "и восстановление",
|
||||
"title": "Резервное копирование",
|
||||
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
|
||||
"export": "Экспорт конфигурации",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Shared locale catalog used by LocaleSelector (settings) and the
|
||||
* template editors (notification & command). Single source of truth so
|
||||
* native names and metadata stay consistent across pickers.
|
||||
*/
|
||||
|
||||
export interface LocaleMeta {
|
||||
code: string;
|
||||
name: string; // English name
|
||||
native: string; // Native script
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
export const LOCALE_CATALOG: LocaleMeta[] = [
|
||||
{ code: 'en', name: 'English', native: 'English' },
|
||||
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', native: 'Français' },
|
||||
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||
];
|
||||
|
||||
export function getLocaleMeta(code: string): LocaleMeta {
|
||||
return LOCALE_CATALOG.find(l => l.code === code) ?? {
|
||||
code,
|
||||
name: code.toUpperCase(),
|
||||
native: code.toUpperCase(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Svelte action that re-parents a node to document.body (or any selector).
|
||||
*
|
||||
* Use this for popups / dropdowns / tooltips that rely on
|
||||
* `position: fixed` positioning. Any ancestor with `backdrop-filter`,
|
||||
* `transform`, `filter`, `perspective`, `contain: paint`, or
|
||||
* `will-change: transform` becomes the containing block for fixed
|
||||
* descendants — which silently breaks viewport-relative positioning.
|
||||
*
|
||||
* Portalling sidesteps that by detaching the node from the component
|
||||
* tree and appending it to a target outside any such ancestor.
|
||||
*
|
||||
* Usage:
|
||||
* <div use:portal>...</div> // → document.body
|
||||
* <div use:portal={'#root'}>...</div> // → custom selector
|
||||
*/
|
||||
export type PortalTarget = string | HTMLElement;
|
||||
|
||||
export function portal(node: HTMLElement, target: PortalTarget = 'body') {
|
||||
function attach(t: PortalTarget) {
|
||||
const el = typeof t === 'string' ? document.querySelector(t) : t;
|
||||
if (el instanceof HTMLElement) el.appendChild(node);
|
||||
}
|
||||
|
||||
attach(target);
|
||||
|
||||
return {
|
||||
update(newTarget: PortalTarget) {
|
||||
attach(newTarget);
|
||||
},
|
||||
destroy() {
|
||||
node.parentNode?.removeChild(node);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
||||
desc: () => '',
|
||||
},
|
||||
|
||||
userFilters: [
|
||||
{
|
||||
key: 'senders',
|
||||
label: 'notificationTracker.userAllowlist',
|
||||
placeholder: 'notificationTracker.selectUsers',
|
||||
icon: 'mdiAccountCheck',
|
||||
},
|
||||
{
|
||||
key: 'exclude_senders',
|
||||
label: 'notificationTracker.userBlocklist',
|
||||
placeholder: 'notificationTracker.selectUsers',
|
||||
icon: 'mdiAccountOff',
|
||||
},
|
||||
],
|
||||
|
||||
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
/**
|
||||
* Today's date in ISO (YYYY-MM-DD) — used as the default for
|
||||
* `periodic_start_date` so new configs anchor to "today" rather than a
|
||||
* hardcoded date that gets further into the past on every release.
|
||||
*/
|
||||
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const immichDescriptor: ProviderDescriptor = {
|
||||
type: 'immich',
|
||||
defaultName: 'Immich',
|
||||
@@ -48,7 +55,7 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
],
|
||||
|
||||
extraTrackingFields: [
|
||||
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' },
|
||||
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
|
||||
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
|
||||
],
|
||||
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
|
||||
enabledField: 'periodic_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
|
||||
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
|
||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
|
||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
|
||||
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
|
||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
||||
enabledField: 'scheduled_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
|
||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
|
||||
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
||||
@@ -79,21 +86,21 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
||||
enabledField: 'memory_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
|
||||
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 },
|
||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
|
||||
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
|
||||
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
||||
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
|
||||
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
|
||||
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
|
||||
enabledField: 'quiet_hours_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
|
||||
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
|
||||
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
|
||||
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
const warnings: { id: string; name: string; issue: string }[] = [];
|
||||
|
||||
// Run shared-link checks in parallel with a concurrency cap so a large
|
||||
// album set doesn't stall the save button for seconds.
|
||||
// album set doesn't stall the save button for seconds. Cap of 6 keeps
|
||||
// the save dialog responsive for users with 50+ albums while staying
|
||||
// well under typical Immich per-IP rate limits.
|
||||
const CONCURRENCY = 6;
|
||||
async function checkOne(albumId: string): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
|
||||
*/
|
||||
export function buildTrackingFormDefaults(): Record<string, any> {
|
||||
const defaults: Record<string, any> = {};
|
||||
// `defaultValue` may be a function (for time-sensitive defaults like
|
||||
// today's date) so the computed value is fresh each time the form resets.
|
||||
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
|
||||
for (const desc of REGISTRY.values()) {
|
||||
for (const field of desc.eventFields) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
for (const extra of desc.extraTrackingFields ?? []) {
|
||||
defaults[extra.key] = extra.defaultValue ?? '';
|
||||
defaults[extra.key] = resolve(extra.defaultValue) ?? '';
|
||||
}
|
||||
for (const section of desc.featureSections ?? []) {
|
||||
defaults[section.enabledField] = section.enabledDefault;
|
||||
for (const f of section.fields) {
|
||||
defaults[f.key] = f.defaultValue ?? '';
|
||||
defaults[f.key] = resolve(f.defaultValue) ?? '';
|
||||
}
|
||||
for (const cb of section.checkboxes ?? []) {
|
||||
defaults[cb.key] = cb.default;
|
||||
|
||||
@@ -60,14 +60,31 @@ export interface EventTrackingField {
|
||||
export interface ExtraTrackingField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'grid-select' | 'toggle';
|
||||
/**
|
||||
* Control kind:
|
||||
* - `number` — numeric spinner
|
||||
* - `grid-select` — icon-grid chooser (requires `gridItems`)
|
||||
* - `toggle` — on/off switch
|
||||
* - `date` — HTML date picker (YYYY-MM-DD)
|
||||
* - `time` — HTML time picker (HH:MM)
|
||||
* - `time-list` — comma-separated HH:MM list, validated on blur
|
||||
*/
|
||||
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
hint?: string;
|
||||
/** Inline helper text rendered under the input (not a tooltip). */
|
||||
inlineHelp?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number | boolean;
|
||||
/** For time-list: show live validation + auto-normalize on blur. */
|
||||
validateFormat?: boolean;
|
||||
/**
|
||||
* Default value. Can be a function for dynamic values (e.g. today's date)
|
||||
* evaluated each time the form is reset.
|
||||
*/
|
||||
defaultValue?: string | number | boolean | (() => string | number | boolean);
|
||||
}
|
||||
|
||||
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
||||
@@ -103,6 +120,25 @@ export interface CollectionMeta {
|
||||
desc: (col: any) => string;
|
||||
}
|
||||
|
||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a filter that picks user identities from the provider's known
|
||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||
* `tracker.filters[key]`.
|
||||
*/
|
||||
export interface UserFilterMeta {
|
||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||
key: string;
|
||||
/** i18n key for the label rendered above the picker. */
|
||||
label: string;
|
||||
/** i18n key for the picker placeholder. */
|
||||
placeholder: string;
|
||||
/** MDI icon shown on chips and dropdown rows. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ── Main descriptor ──────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderDescriptor {
|
||||
@@ -136,6 +172,8 @@ export interface ProviderDescriptor {
|
||||
// ── Collections / Trackers ──
|
||||
/** Null means this provider has no collections (e.g. scheduler). */
|
||||
collectionMeta: CollectionMeta | null;
|
||||
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||
userFilters?: UserFilterMeta[];
|
||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||
webhookBased?: boolean;
|
||||
|
||||
|
||||
@@ -112,6 +112,34 @@ export const capabilitiesCache = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/** Configured external base URL — used to render absolute webhook URLs.
|
||||
* Available to all authenticated users. Empty string when unset. */
|
||||
export const externalUrlCache = (() => {
|
||||
let data = $state<string>('');
|
||||
let fetchedAt = $state(0);
|
||||
let inflight: Promise<string> | null = null;
|
||||
const TTL = 300_000;
|
||||
return {
|
||||
get value() { return data; },
|
||||
invalidate() { fetchedAt = 0; },
|
||||
async fetch(force = false): Promise<string> {
|
||||
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const res = await api<{ external_url: string }>('/settings/external-url');
|
||||
data = (res?.external_url || '').replace(/\/+$/, '');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
/** Supported template locales — fetched from app settings. */
|
||||
export const supportedLocalesCache = (() => {
|
||||
let data = $state<string[]>(['en', 'ru']);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Page-scoped primary action for the global topbar CTA.
|
||||
*
|
||||
* Each route declares its own primary action ("Add Provider",
|
||||
* "New Tracker", etc.) by calling `topbarAction.set({...})`
|
||||
* inside its `onMount`, and clears it on teardown. The layout
|
||||
* reads `topbarAction.current` and renders the button.
|
||||
*
|
||||
* Falls back to the default "New tracker" CTA when no action is
|
||||
* registered (set by the layout itself).
|
||||
*/
|
||||
export interface TopbarAction {
|
||||
/** Visible label, e.g. "Add Provider". */
|
||||
label: string;
|
||||
/** Optional href — renders as <a>. Mutually exclusive with onclick. */
|
||||
href?: string;
|
||||
/** Optional click handler — renders as <button>. */
|
||||
onclick?: () => void;
|
||||
/** Optional MDI/NavIcon name for the leading glyph (default: mdiPlus). */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
let action = $state<TopbarAction | null>(null);
|
||||
|
||||
export const topbarAction = {
|
||||
get current(): TopbarAction | null {
|
||||
return action;
|
||||
},
|
||||
set(next: TopbarAction | null) {
|
||||
action = next;
|
||||
},
|
||||
clear() {
|
||||
action = null;
|
||||
},
|
||||
};
|
||||
@@ -80,7 +80,7 @@ export interface Tracker {
|
||||
provider_id: number;
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
adaptive_max_skip: number | null;
|
||||
default_tracking_config_id: number | null;
|
||||
default_template_config_id: number | null;
|
||||
enabled: boolean;
|
||||
@@ -106,6 +106,7 @@ export interface NotificationTarget {
|
||||
name: string;
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
chat_action?: string | null;
|
||||
chat_name?: string;
|
||||
receiver_count: number;
|
||||
receivers: TargetReceiver[];
|
||||
@@ -216,9 +217,16 @@ export interface EventLog {
|
||||
event_type: string;
|
||||
collection_id: string;
|
||||
collection_name: string;
|
||||
tracker_id?: number | null;
|
||||
tracker_name: string;
|
||||
provider_name: string;
|
||||
provider_id: number | null;
|
||||
action_id?: number | null;
|
||||
action_name?: string;
|
||||
command_tracker_id?: number | null;
|
||||
command_tracker_name?: string;
|
||||
telegram_bot_id?: number | null;
|
||||
bot_name?: string;
|
||||
assets_count: number;
|
||||
details: Record<string, any>;
|
||||
created_at: string;
|
||||
|
||||
+485
-178
@@ -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';
|
||||
|
||||
@@ -37,6 +38,11 @@
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
let _syncingFilter = false;
|
||||
|
||||
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||
// hard reload — the most visible "jump" the user reported.
|
||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||
|
||||
// Sync filter value → store
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
@@ -77,7 +83,24 @@
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||
}
|
||||
|
||||
let collapsed = $state(false);
|
||||
// Read persisted UI state synchronously so first paint already matches the
|
||||
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||
// to collapsed (and groups slide open) right after mount.
|
||||
function readPersistedCollapsed(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||
if (typeof localStorage === 'undefined') return {};
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let collapsed = $state(readPersistedCollapsed());
|
||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||
|
||||
// Nav counts — computed reactively from caches + global provider filter
|
||||
@@ -201,8 +224,21 @@
|
||||
: 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>>({});
|
||||
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||
@@ -218,13 +254,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
|
||||
@@ -241,13 +284,8 @@
|
||||
|
||||
onMount(async () => {
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
if (saved) expandedGroups = JSON.parse(saved);
|
||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
||||
}
|
||||
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||
// their $state initializers above to avoid a post-mount layout snap.
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
redirecting = true;
|
||||
@@ -346,36 +384,41 @@
|
||||
</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">v{__APP_VERSION__}</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>
|
||||
|
||||
<!-- Global provider filter -->
|
||||
{#if allProviders.length >= 1}
|
||||
<!-- Global provider filter — kept rendered during the initial cache
|
||||
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||
push the nav down. Hides only once we confirm zero providers. -->
|
||||
{#if showProviderFilter}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
@@ -384,8 +427,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 +437,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 +453,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 +472,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 +491,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 +504,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 +561,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 +580,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 +593,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 +602,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 +616,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 +628,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 +637,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 +710,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 +815,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>
|
||||
|
||||
+1410
-197
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,19 @@
|
||||
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
||||
enabled: false,
|
||||
});
|
||||
let nameManuallyEdited = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
function actionTypeLabel(at: string): string {
|
||||
return at.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const provider = providers.find((p: any) => p.id === form.provider_id);
|
||||
const at = actionTypeLabel(form.action_type || '');
|
||||
form.name = provider ? `${provider.name} ${at}`.trim() : at || 'Action';
|
||||
}
|
||||
});
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
@@ -68,6 +80,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([
|
||||
@@ -88,6 +110,7 @@
|
||||
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
||||
enabled: false,
|
||||
};
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
|
||||
@@ -99,6 +122,7 @@
|
||||
schedule_interval: action.schedule_interval,
|
||||
schedule_cron: action.schedule_cron, enabled: action.enabled,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = action.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -171,7 +195,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={t('crumbs.routingAutomation')}
|
||||
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 +228,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}
|
||||
@@ -227,13 +259,13 @@
|
||||
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="act-name" bind:value={form.name} required
|
||||
<input id="act-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('actions.schedule')}</label>
|
||||
<div class="block text-sm font-medium mb-1">{t('actions.schedule')}</div>
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<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>
|
||||
|
||||
@@ -30,8 +30,16 @@
|
||||
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
||||
});
|
||||
let emailForm = $state(defaultEmailForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
|
||||
const DEFAULT_BOT_NAME = 'Email Bot';
|
||||
$effect(() => {
|
||||
if (showEmailForm && !nameManuallyEdited && !editingEmail) {
|
||||
emailForm.name = DEFAULT_BOT_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||
@@ -39,6 +47,7 @@
|
||||
smtp_username: bot.smtp_username, smtp_password: '',
|
||||
smtp_use_tls: bot.smtp_use_tls,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editingEmail = bot.id; showEmailForm = true;
|
||||
}
|
||||
|
||||
@@ -54,7 +63,7 @@
|
||||
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotCreated'));
|
||||
}
|
||||
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
|
||||
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
@@ -86,7 +95,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={t('crumbs.operatorsBots')}
|
||||
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>
|
||||
@@ -100,7 +116,7 @@
|
||||
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
||||
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
|
||||
<input id="ebot-name" bind:value={emailForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('emailBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,23 @@
|
||||
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
||||
});
|
||||
let matrixForm = $state(defaultMatrixForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
|
||||
const DEFAULT_BOT_NAME = 'Matrix Bot';
|
||||
$effect(() => {
|
||||
if (showMatrixForm && !nameManuallyEdited && !editingMatrix) {
|
||||
matrixForm.name = DEFAULT_BOT_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
|
||||
function editMatrixBot(bot: MatrixBot) {
|
||||
matrixForm = {
|
||||
name: bot.name, icon: bot.icon || '',
|
||||
homeserver_url: bot.homeserver_url, access_token: '',
|
||||
display_name: bot.display_name || '',
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editingMatrix = bot.id; showMatrixForm = true;
|
||||
}
|
||||
|
||||
@@ -52,7 +61,7 @@
|
||||
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.matrixBotCreated'));
|
||||
}
|
||||
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
|
||||
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
@@ -84,7 +93,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={t('crumbs.operatorsBots')}
|
||||
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>
|
||||
@@ -98,7 +114,7 @@
|
||||
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
|
||||
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
|
||||
<input id="mbot-name" bind:value={matrixForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('matrixBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { slide, fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
@@ -28,13 +29,25 @@
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: '', icon: '', token: '' });
|
||||
let nameManuallyEdited = $state(false);
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
const DEFAULT_BOT_NAME = 'Telegram Bot';
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
form.name = DEFAULT_BOT_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
// Distinct from chatsLoading: refresh keeps the existing list visible
|
||||
// instead of swapping it for a placeholder, avoiding the disorienting
|
||||
// "everything disappears" flash during Discover.
|
||||
let chatsRefreshing = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
// Webhook status per bot
|
||||
@@ -47,8 +60,8 @@
|
||||
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
|
||||
let botListenerLoading = $state<Record<number, boolean>>({});
|
||||
|
||||
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
|
||||
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
|
||||
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
|
||||
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
|
||||
|
||||
async function saveBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
@@ -60,7 +73,7 @@
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.botRegistered'));
|
||||
}
|
||||
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
|
||||
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
@@ -98,12 +111,13 @@
|
||||
}
|
||||
|
||||
async function discoverChats(botId: number) {
|
||||
chatsLoading = { ...chatsLoading, [botId]: true };
|
||||
if (chatsRefreshing[botId]) return;
|
||||
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
|
||||
try {
|
||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatsLoading = { ...chatsLoading, [botId]: false };
|
||||
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||
}
|
||||
|
||||
async function deleteChat(botId: number, chatDbId: number) {
|
||||
@@ -285,7 +299,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={t('crumbs.operatorsBots')}
|
||||
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>
|
||||
@@ -299,7 +320,7 @@
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
<input id="bot-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,10 +355,12 @@
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
|
||||
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}">
|
||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
||||
: (bot.update_mode || 'none') === 'polling'
|
||||
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
@@ -362,66 +385,80 @@
|
||||
<!-- Chats section -->
|
||||
{#if expandedSection[bot.id] === 'chats'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||
{#if chatsLoading[bot.id]}
|
||||
{#if chatsLoading[bot.id] && !chats[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||
<!-- Header -->
|
||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||
<span>{t('telegramBot.chatName')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<!-- Rows -->
|
||||
{#each chats[bot.id] as chat}
|
||||
<div style={gridStyle}
|
||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
aria-label={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<EntitySelect
|
||||
items={LANG_ITEMS}
|
||||
value={chat.language_override || ''}
|
||||
size="sm"
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
<div style="justify-self:end" class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
|
||||
{#if chatsRefreshing[bot.id]}
|
||||
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
|
||||
{/if}
|
||||
<!-- Header -->
|
||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||
<span>{t('telegramBot.chatName')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Rows -->
|
||||
{#each (chats[bot.id] || []) as chat (chat.id)}
|
||||
<div style={gridStyle}
|
||||
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
animate:flip={{ duration: 280 }}
|
||||
in:fade={{ duration: 220, delay: 60 }}
|
||||
out:fade={{ duration: 140 }}
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
aria-label={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<EntitySelect
|
||||
items={LANG_ITEMS}
|
||||
value={chat.language_override || ''}
|
||||
size="sm"
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
<div style="justify-self:end" class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => discoverChats(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.discoverChats')}
|
||||
disabled={chatsRefreshing[bot.id]}
|
||||
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
|
||||
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
</span>
|
||||
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -456,6 +493,14 @@
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||
<button onclick={() => switchMode(bot.id, 'none')}
|
||||
disabled={modeChanging[bot.id] || (bot.update_mode || 'none') === 'none'}
|
||||
class="px-3 py-1 text-xs transition-colors {(bot.update_mode || 'none') === 'none'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiBellOff" size={14} />
|
||||
{t('telegramBot.none')}
|
||||
</button>
|
||||
<button onclick={() => switchMode(bot.id, 'polling')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
||||
@@ -474,6 +519,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if (bot.update_mode || 'none') === 'none'}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
|
||||
<MdiIcon name="mdiBellOff" size={14} />
|
||||
{t('telegramBot.noneActive')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if bot.update_mode === 'polling'}
|
||||
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
|
||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||
@@ -529,3 +581,72 @@
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
/* Chat list — smooth refresh state.
|
||||
The list stays mounted during Discover; we only dim it slightly
|
||||
and run a thin shimmer bar across the top so the user sees
|
||||
"refreshing" instead of "everything vanished and came back". */
|
||||
.chat-list-wrap {
|
||||
position: relative;
|
||||
transition: opacity 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
.chat-list-wrap.is-refreshing {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.9);
|
||||
}
|
||||
.chat-list-wrap.is-refreshing .chat-row {
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
z-index: 2;
|
||||
}
|
||||
.chat-shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chat-shimmer-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.discover-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.discover-icon.is-spinning {
|
||||
animation: discover-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes discover-spin {
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-shimmer::after,
|
||||
.discover-icon.is-spinning {
|
||||
animation: none;
|
||||
}
|
||||
.chat-list-wrap {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import type { CommandConfig } from '$lib/types';
|
||||
|
||||
function templateName(id: number | null): string {
|
||||
@@ -69,6 +70,14 @@
|
||||
command_template_config_id: null as number | null,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const desc = getDescriptor(form.provider_type);
|
||||
form.name = desc ? `${desc.defaultName} Commands` : 'Commands';
|
||||
}
|
||||
});
|
||||
|
||||
let allCapabilities = $derived(capabilitiesCache.items);
|
||||
let providerCommands = $derived<{key: string, icon: string}[]>(
|
||||
@@ -80,6 +89,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([
|
||||
@@ -99,9 +116,31 @@
|
||||
// Auto-select first matching template for the chosen provider_type
|
||||
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
||||
if (match) form.command_template_config_id = match.id;
|
||||
nameManuallyEdited = false;
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
// Re-pick the command-template config when the provider type changes.
|
||||
// The previously-selected id may belong to a different provider type and
|
||||
// would no longer appear in the filtered EntitySelect, leaving it empty.
|
||||
let _prevProviderType = $state('');
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
|
||||
_prevProviderType = form.provider_type;
|
||||
if (editing === null) {
|
||||
const currentTpl = cmdTemplateConfigs.find(
|
||||
(c) => c.id === form.command_template_config_id,
|
||||
);
|
||||
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
|
||||
const first = cmdTemplateConfigs.find(
|
||||
(c) => c.provider_type === form.provider_type,
|
||||
);
|
||||
form.command_template_config_id = first?.id ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
function editConfig(cfg: CommandConfig) {
|
||||
form = {
|
||||
name: cfg.name,
|
||||
@@ -113,6 +152,7 @@
|
||||
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
|
||||
command_template_config_id: cfg.command_template_config_id ?? null,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = cfg.id;
|
||||
showForm = true;
|
||||
}
|
||||
@@ -136,7 +176,7 @@
|
||||
await api('/command-configs', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
@@ -161,7 +201,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={t('crumbs.routingCommands')}
|
||||
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>
|
||||
@@ -177,13 +225,13 @@
|
||||
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
|
||||
<input id="cfg-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label>
|
||||
<div class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</div>
|
||||
{#if !editing}
|
||||
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
|
||||
{:else}
|
||||
@@ -208,30 +256,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';
|
||||
@@ -19,9 +20,13 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import { getLocaleMeta } from '$lib/locales';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
interface CmdTemplateConfig {
|
||||
id: number;
|
||||
@@ -40,6 +45,7 @@
|
||||
}
|
||||
|
||||
let LOCALES = $derived(supportedLocalesCache.items);
|
||||
let primaryLocale = $derived(LOCALES[0] || 'en');
|
||||
|
||||
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
||||
let filterText = $state('');
|
||||
@@ -54,6 +60,11 @@
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let confirmReset = $state<{
|
||||
kind: 'slot' | 'all';
|
||||
slotKey?: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
@@ -67,7 +78,18 @@
|
||||
});
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
let activeLocale = $state<string>('en');
|
||||
let activeLocale = $state<string>('');
|
||||
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
|
||||
const m = getLocaleMeta(code);
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
$effect(() => {
|
||||
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
|
||||
});
|
||||
let expandedSlots = $state<Set<string>>(new Set());
|
||||
let slotFilter = $state('');
|
||||
let showPreviewFor = $state<Set<string>>(new Set());
|
||||
@@ -99,6 +121,14 @@
|
||||
slots: {} as Record<string, Record<string, string>>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const desc = getDescriptor(form.provider_type);
|
||||
form.name = desc ? `${desc.defaultName} Command Templates` : 'Command Templates';
|
||||
}
|
||||
});
|
||||
|
||||
// Provider capabilities
|
||||
let allCapabilities = $state<Record<string, any>>({});
|
||||
@@ -106,11 +136,40 @@
|
||||
let commandSlots = $derived<SlotDef[]>(
|
||||
allCapabilities[form.provider_type]?.command_slots || []
|
||||
);
|
||||
let filteredCmdSlots = $derived(
|
||||
slotFilter
|
||||
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
|
||||
: commandSlots
|
||||
);
|
||||
|
||||
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
|
||||
|
||||
/**
|
||||
* Group command slots by purpose so the form mirrors how notification
|
||||
* templates are split (event vs scheduled vs settings).
|
||||
*
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
*/
|
||||
let commandSlotGroups = $derived([
|
||||
{
|
||||
group: 'commandResponses',
|
||||
slots: commandSlots.filter(s =>
|
||||
!s.name.startsWith('desc_') &&
|
||||
!s.name.startsWith('usage_') &&
|
||||
!ERROR_SLOTS.has(s.name)
|
||||
),
|
||||
},
|
||||
{
|
||||
group: 'commandErrors',
|
||||
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
|
||||
},
|
||||
{
|
||||
group: 'commandDescriptions',
|
||||
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
|
||||
},
|
||||
{
|
||||
group: 'commandUsage',
|
||||
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
|
||||
},
|
||||
]);
|
||||
|
||||
/** Get slot template for current locale, with fallback. */
|
||||
function getSlotValue(slotName: string): string {
|
||||
@@ -135,6 +194,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([
|
||||
@@ -200,9 +266,10 @@
|
||||
form = defaultForm();
|
||||
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
||||
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
||||
nameManuallyEdited = false;
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -223,9 +290,10 @@
|
||||
icon: c.icon || '',
|
||||
slots: slotsCopy,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = c.id;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -253,6 +321,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetSlotToDefault(slotKey: string) {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'slot',
|
||||
slotKey,
|
||||
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function resetAllToDefaults() {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'all',
|
||||
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
async function performReset() {
|
||||
if (!confirmReset || !form.provider_type) return;
|
||||
const { kind, slotKey } = confirmReset;
|
||||
confirmReset = null;
|
||||
try {
|
||||
if (kind === 'slot' && slotKey) {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const text = res?.[slotKey]?.[activeLocale];
|
||||
if (!text) {
|
||||
snackError(t('templateConfig.resetNoDefault'));
|
||||
return;
|
||||
}
|
||||
setSlotValue(slotKey, text);
|
||||
validateSlot(slotKey, text, true);
|
||||
} else {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const nextSlots = { ...form.slots };
|
||||
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||
const text = localeMap?.[activeLocale];
|
||||
if (text === undefined) continue;
|
||||
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||
}
|
||||
form.slots = nextSlots;
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clone(c: CmdTemplateConfig) {
|
||||
const slotsCopy: Record<string, Record<string, string>> = {};
|
||||
for (const [k, v] of Object.entries(c.slots)) {
|
||||
@@ -267,7 +387,7 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -298,11 +418,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={t('crumbs.routingCommands')}
|
||||
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}
|
||||
@@ -316,7 +443,7 @@
|
||||
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
|
||||
<input id="ct-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,7 +455,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}
|
||||
@@ -338,76 +465,98 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Slot filter -->
|
||||
{#if commandSlots.length > 4}
|
||||
<div class="mb-3">
|
||||
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<!-- Language picker -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||
{t('templateConfig.language')}
|
||||
</span>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<EntitySelect
|
||||
items={localeItems}
|
||||
value={activeLocale}
|
||||
size="sm"
|
||||
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
||||
/>
|
||||
</div>
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each filteredCmdSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2 mb-2">
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
||||
{t('templateConfig.preview')}
|
||||
<!-- Slot filter -->
|
||||
{#if commandSlots.length > 4}
|
||||
<div>
|
||||
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
|
||||
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
|
||||
{#if filteredSlots.length > 0}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">
|
||||
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
|
||||
</legend>
|
||||
<div class="space-y-2 mt-2">
|
||||
{#each filteredSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2 mb-2">
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
||||
{t('templateConfig.preview')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if getVarsFor(slot.name)}
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if getVarsFor(slot.name)}
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<JinjaEditor
|
||||
value={getSlotValue(slot.name)}
|
||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
variables={getVarsFor(slot.name) || undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||
</div>
|
||||
{: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>
|
||||
<JinjaEditor
|
||||
value={getSlotValue(slot.name)}
|
||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
variables={getVarsFor(slot.name) || undefined}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{: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}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
@@ -472,6 +621,14 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<ConfirmModal open={confirmReset !== null}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
message={confirmReset?.message || ''}
|
||||
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||
confirmIcon="mdiRefresh"
|
||||
onconfirm={performReset}
|
||||
oncancel={() => confirmReset = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
|
||||
@@ -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';
|
||||
@@ -60,6 +61,14 @@
|
||||
enabled: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const provider = providers.find(p => p.id === form.provider_id);
|
||||
form.name = provider ? `${provider.name} Commands` : 'Commands';
|
||||
}
|
||||
});
|
||||
|
||||
// Filter command configs by selected provider's type
|
||||
let filteredConfigs = $derived.by(() => {
|
||||
@@ -72,7 +81,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([
|
||||
@@ -92,9 +118,30 @@
|
||||
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
|
||||
if (firstCfg) form.command_config_id = firstCfg.id;
|
||||
}
|
||||
nameManuallyEdited = false;
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
// Re-pick the command config when the provider changes. The previously
|
||||
// selected id may belong to a different provider type and would no longer
|
||||
// appear in the filtered EntitySelect, leaving the selector empty.
|
||||
let _prevProviderId = $state(0);
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
if (editing === null) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
|
||||
if (!currentCfg || currentCfg.provider_type !== ptype) {
|
||||
const first = commandConfigs.find(c => c.provider_type === ptype);
|
||||
form.command_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
function editTracker(trk: any) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
@@ -103,6 +150,7 @@
|
||||
command_config_id: trk.command_config_id,
|
||||
enabled: trk.enabled,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = trk.id;
|
||||
showForm = true;
|
||||
}
|
||||
@@ -118,7 +166,7 @@
|
||||
await api('/command-trackers', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandTrackerCreated'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
@@ -226,7 +274,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={t('crumbs.routingCommands')}
|
||||
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>
|
||||
@@ -242,18 +298,18 @@
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
|
||||
<input id="trk-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandTracker.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label>
|
||||
<div class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</div>
|
||||
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('commandTracker.selectProvider')} />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -15,13 +15,32 @@
|
||||
let submitting = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
let backendDown = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
initTheme();
|
||||
mounted = true;
|
||||
// If the user is already signed in (valid access token in storage),
|
||||
// there is no reason to show them the login form. loadUser() runs in
|
||||
// the root layout; we just check the resolved state after a short tick.
|
||||
const { isAuthenticated } = await import('$lib/api');
|
||||
if (isAuthenticated()) {
|
||||
try {
|
||||
await api('/auth/me');
|
||||
goto('/');
|
||||
return;
|
||||
} catch {
|
||||
// Token was stale; fall through to the login form.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||
if (res.needs_setup) goto('/setup');
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// The backend is unreachable — surface that distinctly so the user
|
||||
// doesn't blame the login form for a network/backend problem.
|
||||
backendDown = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
@@ -62,7 +81,12 @@
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
{#if backendDown}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{t('auth.backendUnreachable')}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
|
||||
@@ -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';
|
||||
@@ -45,6 +46,7 @@
|
||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||
let templateConfigs = $derived(templateConfigsCache.items);
|
||||
let collections = $state<Record<string, any>[]>([]);
|
||||
let users = $state<{ id: string; name: string }[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let collectionFilter = $state('');
|
||||
@@ -62,16 +64,25 @@
|
||||
// Tracker form
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||
scan_interval: 60, batch_duration: 0,
|
||||
scan_interval: 60,
|
||||
adaptive_max_skip: null as number | null,
|
||||
default_tracking_config_id: 0, default_template_config_id: 0,
|
||||
filters: {} as Record<string, any>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
let selectedProviderType = $derived(
|
||||
providers.find(p => p.id === form.provider_id)?.type || ''
|
||||
);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const provider = providers.find(p => p.id === form.provider_id);
|
||||
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
|
||||
}
|
||||
});
|
||||
|
||||
// Linked targets management
|
||||
let expandedTracker = $state<number | null>(null);
|
||||
let addingTarget = $state<Record<number, boolean>>({});
|
||||
@@ -84,17 +95,23 @@
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||
// that have those notification slots in their capabilities
|
||||
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
|
||||
// that have those notification slots in their capabilities AND have the feature
|
||||
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// the test button would just surface a silent skip.
|
||||
const allTestTypes: Record<string, {
|
||||
key: string; icon: string; labelKey: string;
|
||||
requiredSlot?: string; enabledField?: string;
|
||||
}> = {
|
||||
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
|
||||
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
|
||||
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
|
||||
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
|
||||
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
|
||||
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
|
||||
};
|
||||
|
||||
let testMenuTrackerId = $state<number | null>(null);
|
||||
let testTypes = $derived.by(() => {
|
||||
const base = [allTestTypes.basic];
|
||||
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
|
||||
if (!testMenuTrackerId) return base;
|
||||
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
||||
if (!tracker) return base;
|
||||
@@ -103,13 +120,41 @@
|
||||
const caps = allCapabilities[provider.type];
|
||||
if (!caps) return base;
|
||||
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
||||
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
|
||||
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
||||
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
|
||||
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
|
||||
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
|
||||
base.push({
|
||||
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
||||
// When surfaced, the button still renders but is disabled and
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// having toggled the feature on Tracking Config see a clear
|
||||
// pointer to the missing setting instead of a silent failure.
|
||||
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
||||
});
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
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 = '';
|
||||
@@ -131,22 +176,38 @@
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!form.provider_id) { users = []; return; }
|
||||
// Skip the fetch when the descriptor has no user filters — saves a
|
||||
// pointless round-trip for providers like Immich/Scheduler.
|
||||
const desc = getDescriptor(selectedProviderType);
|
||||
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||
try { users = await api(`/providers/${form.provider_id}/users`); }
|
||||
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
||||
}
|
||||
|
||||
let _prevProviderId = $state(0);
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
loadCollections();
|
||||
// Auto-select first available tracking/template config for this provider when creating
|
||||
loadUsers();
|
||||
// Re-pick tracking/template configs for the new provider type. The
|
||||
// previously-selected ids may belong to a different provider type
|
||||
// and therefore no longer appear in the filtered EntitySelect list,
|
||||
// which would render the selector as empty.
|
||||
if (editing === null) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
if (!form.default_tracking_config_id) {
|
||||
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
||||
if (!currentTc || currentTc.provider_type !== ptype) {
|
||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_tracking_config_id = first.id;
|
||||
form.default_tracking_config_id = first?.id ?? 0;
|
||||
}
|
||||
if (!form.default_template_config_id) {
|
||||
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
||||
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_template_config_id = first.id;
|
||||
form.default_template_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,21 +218,26 @@
|
||||
form = defaultForm();
|
||||
// Auto-select first provider if any
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||
}
|
||||
|
||||
async function edit(trk: Tracker) {
|
||||
form = {
|
||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||
collection_ids: [...(trk.collection_ids || [])],
|
||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||
scan_interval: trk.scan_interval,
|
||||
adaptive_max_skip: trk.adaptive_max_skip ?? null,
|
||||
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
||||
default_template_config_id: trk.default_template_config_id ?? 0,
|
||||
filters: trk.filters || {},
|
||||
};
|
||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||
nameManuallyEdited = true;
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.provider_id) await loadCollections();
|
||||
if (form.provider_id) {
|
||||
await Promise.all([loadCollections(), loadUsers()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
@@ -207,6 +273,12 @@
|
||||
...form,
|
||||
default_tracking_config_id: form.default_tracking_config_id || null,
|
||||
default_template_config_id: form.default_template_config_id || null,
|
||||
// Empty string, 0, or null all mean "disable adaptive polling".
|
||||
// Coerce to null so the DB column stays NULL rather than 0.
|
||||
adaptive_max_skip:
|
||||
form.adaptive_max_skip && form.adaptive_max_skip > 1
|
||||
? form.adaptive_max_skip
|
||||
: null,
|
||||
};
|
||||
if (editing) {
|
||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
@@ -392,7 +464,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={t('crumbs.routingNotification')}
|
||||
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>
|
||||
@@ -409,6 +489,7 @@
|
||||
bind:form
|
||||
{providerItems}
|
||||
{collections}
|
||||
{users}
|
||||
bind:collectionFilter
|
||||
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||
@@ -420,6 +501,7 @@
|
||||
onsave={save}
|
||||
ontoggleCollection={toggleCollection}
|
||||
{formatDate}
|
||||
onnameinput={() => nameManuallyEdited = true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -448,6 +530,7 @@
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each notificationTrackers as tracker (tracker.id)}
|
||||
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||
<Card hover entityId={tracker.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -460,7 +543,9 @@
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
@@ -516,6 +601,15 @@
|
||||
onclose={() => { linkWarning = null; }}
|
||||
onautoCreate={autoCreateLinks}
|
||||
ondismiss={dismissLinkWarning}
|
||||
onupdate={(remaining) => {
|
||||
if (!linkWarning) return;
|
||||
if (remaining.length === 0) {
|
||||
linkWarning = null;
|
||||
doSave();
|
||||
} else {
|
||||
linkWarning = { ...linkWarning, albums: remaining };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
interface AlbumIssue { id: string; name: string; issue: string }
|
||||
|
||||
interface Props {
|
||||
linkWarning: { albums: any[]; providerId: number } | null;
|
||||
linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
|
||||
linkCreating: boolean;
|
||||
onclose: () => void;
|
||||
onautoCreate: () => void;
|
||||
ondismiss: () => void;
|
||||
/** Called with the updated warning list after a per-row replace. */
|
||||
onupdate?: (albums: AlbumIssue[]) => void;
|
||||
}
|
||||
|
||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
|
||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
|
||||
|
||||
/** Per-row loading state for the "Replace" button. */
|
||||
let replacing = $state<Record<string, boolean>>({});
|
||||
|
||||
/**
|
||||
* Expired and password-protected links can't be repaired in place — the
|
||||
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
||||
* link (which the backend does by POSTing a new one and returning it).
|
||||
* We surface the action per-row so users don't have to leave the form.
|
||||
*/
|
||||
async function replaceOne(album: AlbumIssue) {
|
||||
if (!linkWarning) return;
|
||||
replacing = { ...replacing, [album.id]: true };
|
||||
try {
|
||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ replace: true }),
|
||||
});
|
||||
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||
if (onupdate) onupdate(remaining);
|
||||
} catch (err: any) {
|
||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
||||
} finally {
|
||||
replacing = { ...replacing, [album.id]: false };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
|
||||
@@ -19,13 +52,26 @@
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
<div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
<div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium truncate block">{album.name}</span>
|
||||
{#if album.issue === 'password-protected'}
|
||||
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.linkPasswordProtectedNote')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
{#if album.issue === 'expired' || album.issue === 'password-protected'}
|
||||
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
|
||||
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
|
||||
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
testMenuOpen: string | null;
|
||||
testMenuStyle: string;
|
||||
ttTesting: Record<string, string>;
|
||||
testTypes: { key: string; icon: string; labelKey: string }[];
|
||||
/**
|
||||
* When `disabledReason` is set, the button is rendered greyed out with a
|
||||
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
|
||||
* Summary in Tracking Config first"). Clicking is blocked — clicking an
|
||||
* unconfigured test would have surfaced as a silent server-side skip.
|
||||
*/
|
||||
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
|
||||
ontest: (ttId: number, testType: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
@@ -20,18 +26,27 @@
|
||||
onclick={onclose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
|
||||
{#each testTypes as tt}
|
||||
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
{@const blocked = !!tt.disabledReason}
|
||||
<button
|
||||
onclick={() => ontest(Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
|
||||
disabled={busy || blocked}
|
||||
title={blocked ? t(tt.disabledReason!) : ''}
|
||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
||||
<MdiIcon name={tt.icon} size={14} />
|
||||
{t(tt.labelKey)}
|
||||
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
{#if blocked}
|
||||
<MdiIcon name="mdiLock" size={12} />
|
||||
{/if}
|
||||
{#if busy}
|
||||
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if blocked}
|
||||
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
@@ -15,13 +16,14 @@
|
||||
provider_id: number;
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
adaptive_max_skip: number | null;
|
||||
default_tracking_config_id: number;
|
||||
default_template_config_id: number;
|
||||
filters: Record<string, any>;
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
users?: { id: string; name: string }[];
|
||||
collectionFilter?: string;
|
||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||
@@ -33,12 +35,14 @@
|
||||
onsave: (e: SubmitEvent) => void;
|
||||
ontoggleCollection?: (collectionId: string) => void;
|
||||
formatDate?: (dateStr: string) => string;
|
||||
onnameinput?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
providerItems,
|
||||
collections,
|
||||
users = [],
|
||||
collectionFilter = $bindable(),
|
||||
trackingConfigItems = [],
|
||||
templateConfigItems = [],
|
||||
@@ -50,6 +54,7 @@
|
||||
onsave,
|
||||
ontoggleCollection,
|
||||
formatDate,
|
||||
onnameinput,
|
||||
}: Props = $props();
|
||||
|
||||
let descriptor = $derived(getDescriptor(providerType));
|
||||
@@ -92,16 +97,16 @@
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="trk-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</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,
|
||||
@@ -115,6 +120,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||
{#each descriptor.userFilters as uf (uf.key)}
|
||||
<div>
|
||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[uf.key] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if isScheduler}
|
||||
<!-- Schedule type -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
@@ -167,19 +187,19 @@
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
|
||||
</fieldset>
|
||||
{:else}
|
||||
{#if !isWebhook}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#if !isWebhook}
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="trk-adaptive" class="block text-sm font-medium mb-1">{t('notificationTracker.adaptiveMaxSkip')}<Hint text={t('hints.adaptiveMaxSkip')} /></label>
|
||||
<input id="trk-adaptive" type="number" bind:value={form.adaptive_max_skip} min="0" max="10" placeholder={t('notificationTracker.adaptiveMaxSkipPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Default configs -->
|
||||
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
||||
@@ -199,6 +219,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||
live on the tracking config, not on the tracker itself. Surface this
|
||||
here so users don't have to stumble onto the feature by reading docs. -->
|
||||
{#if providerType === 'immich'}
|
||||
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||
<div class="flex-1 text-xs">
|
||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
<a href={form.default_tracking_config_id
|
||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||
: '/tracking-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
<a href={form.default_template_config_id
|
||||
? `/template-configs?edit=${form.default_template_config_id}`
|
||||
: '/template-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTemplateConfig')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
@@ -43,6 +45,30 @@
|
||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||
|
||||
let descriptor = $derived(getDescriptor(form.type));
|
||||
let externalUrl = $derived(externalUrlCache.value);
|
||||
|
||||
function buildWebhookUrl(pattern: string, token: string): string {
|
||||
const path = pattern.replace('{token}', token ?? '');
|
||||
return externalUrl ? `${externalUrl}${path}` : path;
|
||||
}
|
||||
|
||||
function copyWebhookUrl(e: Event, url: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
snackInfo(`${t('snack.copied')}: ${url}`);
|
||||
}
|
||||
|
||||
// Auto-update name when provider type changes (unless user manually edited)
|
||||
$effect(() => {
|
||||
@@ -54,7 +80,29 @@
|
||||
|
||||
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();
|
||||
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
|
||||
});
|
||||
onDestroy(() => topbarAction.clear());
|
||||
async function load() {
|
||||
try {
|
||||
await providersCache.fetch(true);
|
||||
@@ -146,7 +194,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={t('crumbs.serviceConnections')}
|
||||
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 +227,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}
|
||||
@@ -215,9 +271,15 @@
|
||||
</div>
|
||||
{/each}
|
||||
{#if descriptor?.webhookUrlPattern && editing}
|
||||
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
|
||||
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
||||
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
|
||||
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
|
||||
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
|
||||
<button type="button"
|
||||
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
|
||||
title={t('providers.webhookUrlCopyTitle')}
|
||||
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
|
||||
<code class="bg-transparent">{editingWebhookUrl}</code>
|
||||
</button>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -264,7 +326,14 @@
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||
{/if}
|
||||
{#if provDesc?.webhookUrlPattern}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
|
||||
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
|
||||
{t('providers.webhookUrl')}:
|
||||
<button type="button"
|
||||
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
|
||||
title={t('providers.webhookUrlCopyTitle')}
|
||||
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { externalUrlCache } from '$lib/stores/caches.svelte';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
@@ -37,6 +40,9 @@
|
||||
telegram_asset_cache_max_entries: '5000',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
log_level: 'INFO',
|
||||
log_format: 'text',
|
||||
log_levels: '',
|
||||
});
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
@@ -73,6 +79,7 @@
|
||||
saving = true; error = '';
|
||||
try {
|
||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
||||
externalUrlCache.invalidate();
|
||||
snackSuccess(t('settings.saved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
saving = false;
|
||||
@@ -90,7 +97,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={t('crumbs.systemConfiguration')}
|
||||
/>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
@@ -204,6 +216,30 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Logging section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiTextBoxOutline" size={18} />
|
||||
{t('settings.logging')}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
|
||||
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
|
||||
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
|
||||
<input bind:value={settings.log_levels}
|
||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
|
||||
@@ -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={t('crumbs.systemMaintenance')}
|
||||
/>
|
||||
|
||||
{#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';
|
||||
@@ -128,13 +129,25 @@
|
||||
child_target_ids: [] as number[],
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let submitting = $state(false);
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||
let formEl: HTMLElement;
|
||||
let formEl = $state<HTMLElement | undefined>();
|
||||
|
||||
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
|
||||
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
|
||||
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
|
||||
broadcast: 'Broadcast',
|
||||
};
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
async function scrollToForm() {
|
||||
await tick();
|
||||
@@ -165,6 +178,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([
|
||||
@@ -198,6 +225,7 @@
|
||||
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
|
||||
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
|
||||
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
|
||||
nameManuallyEdited = false;
|
||||
editing = null;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
@@ -214,7 +242,7 @@
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
|
||||
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
|
||||
// discord/slack
|
||||
username: c.username || '',
|
||||
// ntfy
|
||||
@@ -227,6 +255,7 @@
|
||||
// broadcast
|
||||
child_target_ids: c.child_target_ids || [],
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = tgt.id;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
@@ -253,7 +282,7 @@
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
|
||||
ai_captions: form.ai_captions,
|
||||
};
|
||||
} else if (formType === 'webhook') {
|
||||
config = { ai_captions: form.ai_captions };
|
||||
@@ -269,10 +298,12 @@
|
||||
config = { child_target_ids: form.child_target_ids };
|
||||
}
|
||||
|
||||
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
|
||||
if (formType === 'telegram') body.chat_action = form.chat_action || null;
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
|
||||
}
|
||||
showForm = false;
|
||||
editing = null;
|
||||
@@ -418,11 +449,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={t('crumbs.routingTargets')}
|
||||
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}
|
||||
@@ -452,6 +490,7 @@
|
||||
bind:showTelegramSettings
|
||||
onsave={save}
|
||||
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
|
||||
onnameinput={() => nameManuallyEdited = true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
showTelegramSettings: boolean;
|
||||
onsave: (e: SubmitEvent) => void;
|
||||
ontoggleTelegramSettings: () => void;
|
||||
onnameinput?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -70,6 +71,7 @@
|
||||
showTelegramSettings = $bindable(),
|
||||
onsave,
|
||||
ontoggleTelegramSettings,
|
||||
onnameinput,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -79,7 +81,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}
|
||||
@@ -87,12 +89,12 @@
|
||||
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="tgt-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</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 +126,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 +153,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 +161,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 +170,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';
|
||||
@@ -20,11 +21,14 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import { getLocaleMeta } from '$lib/locales';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import type { TemplateConfig } from '$lib/types';
|
||||
|
||||
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
||||
@@ -42,6 +46,17 @@
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
/**
|
||||
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
|
||||
* single-slot reset (slotKey populated); ``'all'`` confirms a full
|
||||
* locale-scoped wipe. Split from confirmDelete so the two flows can
|
||||
* coexist without stomping each other's state mid-dialog.
|
||||
*/
|
||||
let confirmReset = $state<{
|
||||
kind: 'slot' | 'all';
|
||||
slotKey?: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
@@ -59,7 +74,24 @@
|
||||
let showPreviewFor = $state<Set<string>>(new Set());
|
||||
|
||||
let LOCALES = $derived(supportedLocalesCache.items);
|
||||
let activeLocale = $state<string>('en');
|
||||
let primaryLocale = $derived(LOCALES[0] || 'en');
|
||||
let activeLocale = $state<string>('');
|
||||
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
|
||||
const m = getLocaleMeta(code);
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
/**
|
||||
* Promote primary to be the active locale once the supported-locales
|
||||
* cache loads (covers initial mount before openNew/edit ran). Without
|
||||
* this, opening a form before fetch resolves would stay on '' / 'en'.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
|
||||
});
|
||||
|
||||
function toggleSlot(key: string) {
|
||||
const next = new Set(expandedSlots);
|
||||
@@ -163,8 +195,16 @@
|
||||
date_only_format: '%d.%m.%Y',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let nameManuallyEdited = $state(false);
|
||||
let previewTargetType = $state('telegram');
|
||||
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const desc = getDescriptor(form.provider_type);
|
||||
form.name = desc ? `${desc.defaultName} Templates` : 'Templates';
|
||||
}
|
||||
});
|
||||
|
||||
// Provider capabilities: from shared cache
|
||||
let allCapabilities = $derived(capabilitiesCache.items);
|
||||
let providerTypes = $derived(Object.keys(allCapabilities));
|
||||
@@ -196,7 +236,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([
|
||||
@@ -206,13 +261,65 @@
|
||||
supportedLocalesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||
}
|
||||
|
||||
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
|
||||
// config in edit mode. Mirrors the same hook on tracking-configs so the
|
||||
// Notification Tracker form can link directly to the editor instead of
|
||||
// the generic list. Strips the param afterwards so a browser refresh
|
||||
// doesn't re-open the modal.
|
||||
function _openEditFromUrl() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const editId = params.get('edit');
|
||||
if (!editId) return;
|
||||
const match = allTemplateConfigs.find(c => String(c.id) === editId);
|
||||
if (match) edit(match);
|
||||
params.delete('edit');
|
||||
const qs = params.toString();
|
||||
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
|
||||
* other pages (currently the tracking-configs Preview-template modal).
|
||||
* Picks the first visible config matching ``provider``, opens it in edit
|
||||
* mode, and pre-expands the target slot. Strips the param from the URL so
|
||||
* a subsequent reload doesn't reopen the form unexpectedly.
|
||||
*/
|
||||
function handleDeepLink() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slot = params.get('edit_slot');
|
||||
if (!slot) return;
|
||||
const provider = params.get('provider') || '';
|
||||
const target = allTemplateConfigs.find(
|
||||
c => !provider || c.provider_type === provider,
|
||||
);
|
||||
// Strip the deep-link param so reload/back doesn't replay it.
|
||||
params.delete('edit_slot');
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
|
||||
if (!target) {
|
||||
snackError(t('templateConfig.deepLinkNoConfig'));
|
||||
return;
|
||||
}
|
||||
edit(target);
|
||||
expandedSlots = new Set([slot]);
|
||||
// Scroll the slot into view once the form has rendered.
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`slot-${slot}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
|
||||
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
refreshDateFormatPreview();
|
||||
}
|
||||
function edit(c: TemplateConfig) {
|
||||
@@ -225,7 +332,8 @@
|
||||
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: c.date_only_format || '%d.%m.%Y',
|
||||
};
|
||||
editing = c.id; showForm = true; activeLocale = 'en';
|
||||
nameManuallyEdited = true;
|
||||
editing = c.id; showForm = true; activeLocale = primaryLocale;
|
||||
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
||||
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
@@ -241,6 +349,65 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to confirm a reset. The actual fetch+replace runs in
|
||||
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
|
||||
* two steps so we can use the app-wide ConfirmModal (consistent look,
|
||||
* keyboard handling) instead of ``window.confirm`` (blocks the page).
|
||||
*/
|
||||
function resetSlotToDefault(slotKey: string) {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'slot',
|
||||
slotKey,
|
||||
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function resetAllToDefaults() {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'all',
|
||||
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
async function performReset() {
|
||||
if (!confirmReset || !form.provider_type) return;
|
||||
const { kind, slotKey } = confirmReset;
|
||||
confirmReset = null;
|
||||
try {
|
||||
if (kind === 'slot' && slotKey) {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const text = res?.[slotKey]?.[activeLocale];
|
||||
if (!text) {
|
||||
snackError(t('templateConfig.resetNoDefault'));
|
||||
return;
|
||||
}
|
||||
setSlotValue(slotKey, text);
|
||||
validateSlot(slotKey, text, true);
|
||||
} else {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
// Replace current-locale slots; leave other locales' values untouched.
|
||||
const nextSlots = { ...form.slots };
|
||||
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||
const text = localeMap?.[activeLocale];
|
||||
if (text === undefined) continue;
|
||||
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||
}
|
||||
form.slots = nextSlots;
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clone(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
@@ -253,7 +420,7 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
@@ -276,7 +443,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={t('crumbs.routingNotification')}
|
||||
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>
|
||||
@@ -293,7 +468,7 @@
|
||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
|
||||
<input id="tpc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('templateConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +480,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}
|
||||
@@ -316,19 +491,31 @@
|
||||
{/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>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
|
||||
{loc.toUpperCase()}
|
||||
<!-- Language picker -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||
{t('templateConfig.language')}
|
||||
</span>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<EntitySelect
|
||||
items={localeItems}
|
||||
value={activeLocale}
|
||||
size="sm"
|
||||
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
||||
/>
|
||||
</div>
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Slot filter -->
|
||||
@@ -349,9 +536,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]}
|
||||
@@ -361,6 +548,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="slot-{slot.key}">
|
||||
<CollapsibleSlot
|
||||
label={slot.key}
|
||||
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
|
||||
@@ -379,6 +567,11 @@
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||
@@ -391,12 +584,13 @@
|
||||
|
||||
{#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}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -466,6 +660,14 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<ConfirmModal open={confirmReset !== null}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
message={confirmReset?.message || ''}
|
||||
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||
confirmIcon="mdiRefresh"
|
||||
onconfirm={performReset}
|
||||
oncancel={() => confirmReset = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
|
||||
@@ -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';
|
||||
@@ -12,6 +13,9 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
@@ -22,13 +26,150 @@
|
||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { TrackingConfig } from '$lib/types';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||
};
|
||||
|
||||
/**
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||
* are surfaced inline next to the input.
|
||||
*/
|
||||
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
||||
|
||||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||
let timeListErrors = $state<Record<string, string>>({});
|
||||
|
||||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||
function normalizeTimeList(key: string) {
|
||||
const raw = String(form[key] ?? '').trim();
|
||||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||
if (!TIME_LIST_RE.test(raw)) {
|
||||
// Try a lenient normalization: split on commas, zero-pad each part.
|
||||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const fixed: string[] = [];
|
||||
let ok = true;
|
||||
for (const p of parts) {
|
||||
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
|
||||
if (!m) { ok = false; break; }
|
||||
const hh = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
|
||||
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
|
||||
}
|
||||
if (ok) {
|
||||
form[key] = fixed.join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
return;
|
||||
}
|
||||
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
|
||||
return;
|
||||
}
|
||||
// Canonicalise spacing.
|
||||
form[key] = raw.split(',').map(s => s.trim()).join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* (start > end) correctly.
|
||||
*/
|
||||
function quietHoursPreview(start: string, end: string): string {
|
||||
if (!start || !end) return '';
|
||||
const [sh, sm] = start.split(':').map(Number);
|
||||
const [eh, em] = end.split(':').map(Number);
|
||||
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
|
||||
const sMin = sh * 60 + sm;
|
||||
const eMin = eh * 60 + em;
|
||||
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
|
||||
const overnight = sMin > eMin;
|
||||
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
|
||||
const h = Math.floor(span / 60);
|
||||
const m = span % 60;
|
||||
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
const arrow = overnight
|
||||
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} → ${end}`;
|
||||
return `${arrow} (${dur})`;
|
||||
}
|
||||
|
||||
function gotoTemplateConfig(slotName: string) {
|
||||
// Deep-link to the template configs page: pass the slot as a query
|
||||
// param (``edit_slot``) so the destination can auto-open the first
|
||||
// matching config in edit mode and expand that slot. Plain hashes
|
||||
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
|
||||
// until a config is being edited.
|
||||
const u = new URL('/template-configs', window.location.origin);
|
||||
u.searchParams.set('provider', 'immich');
|
||||
u.searchParams.set('edit_slot', slotName);
|
||||
window.location.href = u.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline preview of the shipped default template for a scheduled/periodic/
|
||||
* memory slot. Using the shipped default (not a tracker's current template)
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||||
* actual config can click "Edit template" in the modal footer.
|
||||
*
|
||||
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
*/
|
||||
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
let previewLocales = $derived(supportedLocalesCache.items);
|
||||
|
||||
async function openTemplatePreview(slotName: string) {
|
||||
await supportedLocalesCache.fetch();
|
||||
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
|
||||
await renderPreviewFor(slotName, initialLocale);
|
||||
}
|
||||
|
||||
async function renderPreviewFor(slotName: string, locale: string) {
|
||||
previewLoading = true;
|
||||
try {
|
||||
const defaults = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const template = defaults?.[slotName]?.[locale];
|
||||
if (!template) {
|
||||
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
|
||||
return;
|
||||
}
|
||||
const res = await api<{ rendered?: string; error?: string }>(
|
||||
'/template-configs/preview-raw',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template,
|
||||
target_type: 'telegram',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: '%d.%m.%Y',
|
||||
}),
|
||||
},
|
||||
);
|
||||
previewModal = {
|
||||
slotName,
|
||||
rendered: res?.rendered || '',
|
||||
error: res?.error || '',
|
||||
locale,
|
||||
};
|
||||
} catch (err: any) {
|
||||
previewModal = { slotName, rendered: '', error: err.message, locale };
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const SLOT_FOR_SECTION: Record<string, string> = {
|
||||
periodic: 'periodic_summary_message',
|
||||
scheduled: 'scheduled_assets_message',
|
||||
memory: 'memory_mode_message',
|
||||
};
|
||||
|
||||
let allConfigs = $derived(trackingConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
@@ -49,17 +190,58 @@
|
||||
});
|
||||
let form: Record<string, any> = $state(defaultForm());
|
||||
let descriptor = $derived(getDescriptor(form.provider_type));
|
||||
let nameManuallyEdited = $state(false);
|
||||
|
||||
onMount(load);
|
||||
$effect(() => {
|
||||
if (showForm && !nameManuallyEdited && !editing) {
|
||||
const desc = getDescriptor(form.provider_type);
|
||||
form.name = desc ? `${desc.defaultName} Tracking` : 'Tracking';
|
||||
}
|
||||
});
|
||||
|
||||
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); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
// Cross-page deep-link: ``/tracking-configs?edit=<id>`` auto-opens that
|
||||
// config in edit mode. Used by the Notification Tracker form's "Open
|
||||
// Tracking Config" link so users land directly on the right editor
|
||||
// instead of the generic list. Strips the param afterwards so a browser
|
||||
// refresh doesn't re-open the modal.
|
||||
function _openEditFromUrl() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const editId = params.get('edit');
|
||||
if (!editId) return;
|
||||
const match = allConfigs.find(c => String(c.id) === editId);
|
||||
if (match) edit(match);
|
||||
params.delete('edit');
|
||||
const qs = params.toString();
|
||||
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
nameManuallyEdited = true;
|
||||
editing = c.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -90,7 +272,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={t('crumbs.routingNotification')}
|
||||
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>
|
||||
@@ -107,13 +297,13 @@
|
||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||
<input id="tc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</label>
|
||||
<div class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</div>
|
||||
{#if !editing}
|
||||
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
|
||||
{:else}
|
||||
@@ -161,10 +351,20 @@
|
||||
{t(section.legend)}
|
||||
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
||||
</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1">
|
||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||
{t('trackingConfig.enabled')}
|
||||
</label>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||
{t('trackingConfig.enabled')}
|
||||
</label>
|
||||
{#if SLOT_FOR_SECTION[section.key]}
|
||||
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
|
||||
disabled={previewLoading}>
|
||||
<MdiIcon name="mdiEyeOutline" size={14} />
|
||||
{t('trackingConfig.previewTemplate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if form[section.enabledField]}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
{#each section.fields as field (field.key)}
|
||||
@@ -181,17 +381,32 @@
|
||||
{:else if field.type === 'grid-select' && field.gridItems}
|
||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||
{:else}
|
||||
<input type={field.key.includes('date') ? 'date'
|
||||
: field.key.startsWith('quiet_hours_') ? 'time'
|
||||
: field.key.includes('times') ? 'text'
|
||||
: 'number'}
|
||||
{@const inputType = field.type === 'date' ? 'date'
|
||||
: field.type === 'time' ? 'time'
|
||||
: field.type === 'time-list' ? 'text'
|
||||
: 'number'}
|
||||
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
||||
<input type={inputType}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
|
||||
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
|
||||
{#if field.inlineHelp}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||
{/if}
|
||||
{#if hasError}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
|
||||
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiWeatherNight" size={12} />
|
||||
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
@@ -268,7 +483,63 @@
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<Modal open={previewModal !== null}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
onclose={() => previewModal = null}>
|
||||
{#if previewModal}
|
||||
{#if previewLocales.length > 1}
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each previewLocales as loc}
|
||||
<button type="button"
|
||||
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
|
||||
disabled={previewLoading}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('trackingConfig.previewSampleNote')}
|
||||
</p>
|
||||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
placeholder caused a one-frame layout jump as the modal shrank and
|
||||
then re-expanded. -->
|
||||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||
{#if previewModal.error}
|
||||
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
{previewModal.error}
|
||||
</div>
|
||||
{:else if previewModal.rendered}
|
||||
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-3">
|
||||
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
|
||||
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
|
||||
{t('trackingConfig.editTemplate')}
|
||||
</button>
|
||||
<button type="button" onclick={() => previewModal = null}
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
:global(.preview-html a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
:global(.preview-html a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -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={t('crumbs.systemAccess')}
|
||||
count={users.length}
|
||||
countLabel={t('users.countLabel')}
|
||||
>
|
||||
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.2.6"
|
||||
version = "0.7.1"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Request-scoped ContextVars that propagate into log records.
|
||||
|
||||
The server sets these at entry points (Telegram webhook, scheduler dispatch,
|
||||
REST call) and they propagate through async calls automatically. A
|
||||
``LogRecordFactory`` installed by ``notify_bridge_server.logging_setup``
|
||||
reads them so every log line is tagged (``request_id``, ``command``,
|
||||
``chat_id``, ``bot_id``, ``dispatch_id``) without each call site having
|
||||
to pass the values explicitly.
|
||||
|
||||
Kept in ``notify_bridge_core`` so core modules (``TelegramClient``,
|
||||
``NotificationDispatcher``) can *set* additional context (e.g. a
|
||||
``dispatch_id``) without depending on the server package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, Iterator
|
||||
|
||||
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
command_var: ContextVar[str | None] = ContextVar("command", default=None)
|
||||
chat_id_var: ContextVar[str | None] = ContextVar("chat_id", default=None)
|
||||
bot_id_var: ContextVar[int | None] = ContextVar("bot_id", default=None)
|
||||
dispatch_id_var: ContextVar[str | None] = ContextVar("dispatch_id", default=None)
|
||||
|
||||
_VAR_MAP: dict[str, ContextVar[Any]] = {
|
||||
"request_id": request_id_var,
|
||||
"command": command_var,
|
||||
"chat_id": chat_id_var,
|
||||
"bot_id": bot_id_var,
|
||||
"dispatch_id": dispatch_id_var,
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_log_context(**kwargs: Any) -> Iterator[None]:
|
||||
"""Bind the given context fields for the duration of the ``with`` block.
|
||||
|
||||
Unknown keys are ignored so callers can pass whatever they want without
|
||||
an ``if`` ladder. Values are reset on exit even if the block raises.
|
||||
|
||||
Example:
|
||||
``with bind_log_context(request_id="abc", command="random"): ...``
|
||||
"""
|
||||
tokens: list[tuple[ContextVar[Any], Token]] = []
|
||||
try:
|
||||
for key, value in kwargs.items():
|
||||
var = _VAR_MAP.get(key)
|
||||
if var is None:
|
||||
continue
|
||||
tokens.append((var, var.set(value)))
|
||||
yield
|
||||
finally:
|
||||
for var, tok in tokens:
|
||||
var.reset(tok)
|
||||
|
||||
|
||||
def current_log_context() -> dict[str, Any]:
|
||||
"""Return a snapshot of the currently-bound context values (non-None)."""
|
||||
snap: dict[str, Any] = {}
|
||||
for key, var in _VAR_MAP.items():
|
||||
val = var.get()
|
||||
if val is not None:
|
||||
snap[key] = val
|
||||
return snap
|
||||
@@ -4,21 +4,24 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..http_base import HttpProviderClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Discord webhook content limit
|
||||
MAX_CONTENT_LENGTH = 2000
|
||||
# Discord API constraints (per webhook docs).
|
||||
MAX_CONTENT_LENGTH: Final = 2000
|
||||
MAX_USERNAME_LENGTH: Final = 80
|
||||
|
||||
|
||||
class DiscordClient:
|
||||
class DiscordClient(HttpProviderClient):
|
||||
"""Sends messages via Discord webhook URLs."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||
self._session = session
|
||||
super().__init__(session, provider_name="discord")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -33,6 +36,8 @@ class DiscordClient:
|
||||
"""
|
||||
if not webhook_url:
|
||||
return {"success": False, "error": "Missing webhook_url"}
|
||||
if username and len(username) > MAX_USERNAME_LENGTH:
|
||||
return {"success": False, "error": f"username exceeds {MAX_USERNAME_LENGTH} chars"}
|
||||
|
||||
chunks = _split_message(message, MAX_CONTENT_LENGTH)
|
||||
for chunk in chunks:
|
||||
@@ -42,47 +47,34 @@ class DiscordClient:
|
||||
if avatar_url:
|
||||
payload["avatar_url"] = avatar_url
|
||||
|
||||
result = await self._post(webhook_url, payload)
|
||||
if not result["success"]:
|
||||
result = await self.request("POST", webhook_url, json=payload)
|
||||
if not result.get("success"):
|
||||
return result
|
||||
|
||||
# Small delay between chunks to respect rate limits
|
||||
if len(chunks) > 1:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
|
||||
try:
|
||||
async with self._session.post(
|
||||
url, json=payload, headers={"Content-Type": "application/json"}
|
||||
) as resp:
|
||||
if resp.status == 429:
|
||||
retry_after = float(resp.headers.get("Retry-After", "2"))
|
||||
_LOGGER.warning("Discord rate limited, retrying after %.1fs", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
return await self._post(url, payload)
|
||||
if 200 <= resp.status < 300:
|
||||
return {"success": True}
|
||||
body = await resp.text()
|
||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _split_message(text: str, limit: int) -> list[str]:
|
||||
"""Split message into chunks respecting the character limit."""
|
||||
"""Split message into chunks respecting the character limit.
|
||||
|
||||
Drops chunks that contain only whitespace — Discord rejects those.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
chunks: list[str] = []
|
||||
while text:
|
||||
if len(text) <= limit:
|
||||
chunks.append(text)
|
||||
break
|
||||
# Try to split at newline
|
||||
split_at = text.rfind("\n", 0, limit)
|
||||
if split_at <= 0:
|
||||
split_at = limit
|
||||
chunks.append(text[:split_at])
|
||||
text = text[split_at:].lstrip("\n")
|
||||
return chunks
|
||||
piece = text
|
||||
text = ""
|
||||
else:
|
||||
split_at = text.rfind("\n", 0, limit)
|
||||
if split_at <= 0:
|
||||
split_at = limit
|
||||
piece = text[:split_at]
|
||||
text = text[split_at:].lstrip("\n")
|
||||
if piece.strip():
|
||||
chunks.append(piece)
|
||||
return chunks or [text]
|
||||
|
||||
@@ -3,46 +3,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Final
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.templates.context import build_template_context
|
||||
from notify_bridge_core.templates.renderer import render_template
|
||||
from .ssrf import UnsafeURLError, validate_outbound_url
|
||||
|
||||
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
# Cap on how many asset downloads run concurrently inside
|
||||
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
||||
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
||||
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
|
||||
# contains many large videos.
|
||||
_PRELOAD_CONCURRENCY = 6
|
||||
|
||||
|
||||
def _new_session() -> aiohttp.ClientSession:
|
||||
"""Per-dispatch aiohttp session with a sane default timeout.
|
||||
|
||||
We still open a short-lived session per dispatch (connection reuse across
|
||||
dispatches lives in the server-side shared session), but we always attach
|
||||
a total timeout so a hung peer cannot wedge the task forever.
|
||||
"""
|
||||
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
|
||||
|
||||
from .http_base import safe_headers
|
||||
from .receiver import (
|
||||
DiscordReceiver,
|
||||
EmailReceiver,
|
||||
MatrixReceiver,
|
||||
NtfyReceiver,
|
||||
Receiver,
|
||||
SlackReceiver,
|
||||
TelegramReceiver,
|
||||
WebhookReceiver,
|
||||
EmailReceiver,
|
||||
DiscordReceiver,
|
||||
SlackReceiver,
|
||||
NtfyReceiver,
|
||||
MatrixReceiver,
|
||||
)
|
||||
from .redact import redact_exc
|
||||
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
||||
from .telegram.cache import TelegramFileCache
|
||||
from .telegram.client import TelegramClient
|
||||
from .telegram.media import (
|
||||
@@ -55,7 +41,33 @@ from .webhook.client import WebhookClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
|
||||
DEFAULT_TEMPLATE: Final = '{{ event_type }}: "{{ collection_name }}"'
|
||||
|
||||
_HTTP_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
|
||||
# Cap on how many asset downloads run concurrently inside
|
||||
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
||||
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
||||
# max_asset_size``.
|
||||
_PRELOAD_CONCURRENCY: Final = 6
|
||||
|
||||
# Cap on how many targets the dispatcher fans out to at once. With dozens
|
||||
# of targets and a single hung peer, unbounded ``gather`` can pin the
|
||||
# dispatch task. The cap also protects against credential-reuse rate
|
||||
# limits on shared providers.
|
||||
_DISPATCH_CONCURRENCY: Final = 16
|
||||
|
||||
# Cap on parallel per-receiver sends within a single target.
|
||||
_RECEIVER_CONCURRENCY: Final = 8
|
||||
|
||||
# Per-target soft timeout — at the top of the dispatch tree so a single
|
||||
# misbehaving target can't hold the whole batch open. Individual provider
|
||||
# clients carry their own per-request timeouts on top of this.
|
||||
_TARGET_TIMEOUT_S: Final = 120.0
|
||||
|
||||
|
||||
def _new_session() -> aiohttp.ClientSession:
|
||||
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -63,17 +75,23 @@ class TargetConfig:
|
||||
"""Configuration for a notification target."""
|
||||
|
||||
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
|
||||
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
|
||||
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
|
||||
locale: str = "en" # default locale for template resolution
|
||||
config: dict[str, Any]
|
||||
template_slots: dict[str, dict[str, str]] | None = None
|
||||
locale: str = "en"
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
provider_api_key: str | None = None # API key for downloading assets from provider
|
||||
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
||||
provider_external_url: str | None = None # External domain for API key scoping
|
||||
provider_api_key: str | None = None
|
||||
provider_internal_url: str | None = None
|
||||
provider_external_url: str | None = None
|
||||
receivers: list[Receiver] = field(default_factory=list)
|
||||
|
||||
|
||||
_SendMethod = Callable[
|
||||
["NotificationDispatcher", TargetConfig, str, ServiceEvent],
|
||||
Awaitable[dict[str, Any]],
|
||||
]
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
"""Dispatches ServiceEvent notifications to configured targets."""
|
||||
|
||||
@@ -82,9 +100,21 @@ class NotificationDispatcher:
|
||||
*,
|
||||
url_cache: TelegramFileCache | None = None,
|
||||
asset_cache: TelegramFileCache | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
self._url_cache = url_cache
|
||||
self._asset_cache = asset_cache
|
||||
# Optional shared session owned by the caller; when supplied we reuse
|
||||
# its connection pool instead of opening a fresh per-dispatch session.
|
||||
self._shared_session = session
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
||||
if self._shared_session is not None and not self._shared_session.closed:
|
||||
yield self._shared_session
|
||||
return
|
||||
async with _new_session() as session:
|
||||
yield session
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
@@ -93,25 +123,60 @@ class NotificationDispatcher:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Send event notification to all targets.
|
||||
|
||||
Returns list of results (one per target).
|
||||
Returns one result per target. Per-target failures are isolated;
|
||||
a single bad target cannot poison the batch.
|
||||
"""
|
||||
raw_results = await asyncio.gather(
|
||||
*[self._send_to_target(event, t) for t in targets],
|
||||
return_exceptions=True,
|
||||
)
|
||||
results = []
|
||||
for raw in raw_results:
|
||||
if isinstance(raw, Exception):
|
||||
_LOGGER.error("Failed to dispatch to target: %s", raw)
|
||||
results.append({"success": False, "error": str(raw)})
|
||||
else:
|
||||
results.append(raw)
|
||||
return results
|
||||
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
with bind_log_context(dispatch_id=new_id):
|
||||
_LOGGER.info(
|
||||
"Dispatching event %s (collection=%r) to %d target(s)",
|
||||
event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
|
||||
getattr(event, "collection_name", None), len(targets),
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(_DISPATCH_CONCURRENCY)
|
||||
|
||||
async def run_one(t: TargetConfig) -> dict[str, Any]:
|
||||
async with sem:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._send_to_target(event, t),
|
||||
timeout=_TARGET_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Target dispatch timed out after {_TARGET_TIMEOUT_S}s",
|
||||
}
|
||||
|
||||
raw_results = await asyncio.gather(
|
||||
*[run_one(t) for t in targets],
|
||||
return_exceptions=True,
|
||||
)
|
||||
results: list[dict[str, Any]] = []
|
||||
failures = 0
|
||||
for target, raw in zip(targets, raw_results):
|
||||
if isinstance(raw, Exception):
|
||||
failures += 1
|
||||
_LOGGER.error(
|
||||
"Dispatch to target type=%s failed: %s",
|
||||
target.type, redact_exc(raw),
|
||||
)
|
||||
results.append({"success": False, "error": redact_exc(raw)})
|
||||
else:
|
||||
if isinstance(raw, dict) and not raw.get("success"):
|
||||
failures += 1
|
||||
results.append(raw)
|
||||
_LOGGER.info(
|
||||
"Dispatch finished: %d target(s), %d failure(s)",
|
||||
len(targets), failures,
|
||||
)
|
||||
return results
|
||||
|
||||
def _resolve_template(
|
||||
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||
) -> str:
|
||||
"""Resolve template string for an event, with locale fallback."""
|
||||
template_str = DEFAULT_TEMPLATE
|
||||
if target.template_slots:
|
||||
locale_map = target.template_slots.get(event.event_type.value)
|
||||
@@ -122,7 +187,6 @@ class NotificationDispatcher:
|
||||
def _render_message(
|
||||
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||
) -> str:
|
||||
"""Resolve template and render message for a given locale."""
|
||||
template_str = self._resolve_template(event, target, locale)
|
||||
ctx = build_template_context(
|
||||
event, target_type=target.type,
|
||||
@@ -135,7 +199,6 @@ class NotificationDispatcher:
|
||||
self, receiver: Receiver, default_message: str,
|
||||
event: ServiceEvent, target: TargetConfig,
|
||||
) -> str:
|
||||
"""Return per-receiver message, re-rendering if receiver has a different locale."""
|
||||
if receiver.locale and receiver.locale != target.locale:
|
||||
return self._render_message(event, target, receiver.locale)
|
||||
return default_message
|
||||
@@ -143,21 +206,16 @@ class NotificationDispatcher:
|
||||
async def _send_to_target(
|
||||
self, event: ServiceEvent, target: TargetConfig
|
||||
) -> dict[str, Any]:
|
||||
"""Send event to a single target (potentially multiple receivers)."""
|
||||
"""Dispatch to a single target via the registered handler."""
|
||||
default_message = self._render_message(event, target, target.locale)
|
||||
send_method = _PROVIDER_HANDLERS.get(target.type)
|
||||
if send_method is None:
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
return await send_method(self, target, default_message, event)
|
||||
|
||||
send_method = {
|
||||
"telegram": self._send_telegram,
|
||||
"webhook": self._send_webhook,
|
||||
"email": self._send_email,
|
||||
"discord": self._send_discord,
|
||||
"slack": self._send_slack,
|
||||
"ntfy": self._send_ntfy,
|
||||
"matrix": self._send_matrix,
|
||||
}.get(target.type)
|
||||
if send_method:
|
||||
return await send_method(target, default_message, event)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
# ------------------------------------------------------------------
|
||||
# Asset preload (Telegram-specific)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _preload_asset_data(
|
||||
self,
|
||||
@@ -166,36 +224,13 @@ class NotificationDispatcher:
|
||||
session: aiohttp.ClientSession,
|
||||
max_size: int | None,
|
||||
) -> None:
|
||||
"""Download each non-cached asset's bytes once and attach to the entry.
|
||||
|
||||
Three benefits:
|
||||
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
|
||||
so we don't fetch each URL twice.
|
||||
* We know the exact upload size, which lets the oversize warning in
|
||||
the rendered text compare against real bytes (for Immich videos,
|
||||
the transcoded ``/video/playback``), not the original ``file_size``.
|
||||
* Assets already in the Telegram file_id cache are skipped, and their
|
||||
stored size (if any) is used to populate ``playback_size`` — so
|
||||
templates see consistent sizes for repeat sends without re-download.
|
||||
|
||||
Entries whose download fails or exceeds ``max_size`` are left without
|
||||
``data``; ``TelegramClient`` will then fall back to its own download
|
||||
path and apply the same checks — no regression, just no preload win.
|
||||
|
||||
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
|
||||
stays predictable: at most N assets worth of bytes held in RAM at
|
||||
once, regardless of ``max_media_to_send``. Total wall-clock is
|
||||
unchanged for small batches and only marginally slower for large
|
||||
ones (most assets fit in a single RTT and SSL negotiation cost
|
||||
dominates, so 6-way parallelism is sufficient).
|
||||
"""
|
||||
"""Download each non-cached asset's bytes once, with SSRF guard."""
|
||||
if not assets:
|
||||
return
|
||||
|
||||
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
|
||||
|
||||
async def _fetch(entry: dict[str, Any], media: Any) -> None:
|
||||
# Cache hit → skip download; populate playback_size from stored size.
|
||||
async def fetch(entry: dict[str, Any], media: Any) -> None:
|
||||
cache, key = self._cache_for_entry(entry)
|
||||
if cache and key:
|
||||
cached = cache.get(key)
|
||||
@@ -207,28 +242,40 @@ class NotificationDispatcher:
|
||||
|
||||
url = entry["url"]
|
||||
headers = entry.get("headers") or {}
|
||||
try:
|
||||
# Defense-in-depth: validate even though TelegramClient
|
||||
# also validates. The dispatcher is what triggers the
|
||||
# download, so the guard belongs here too.
|
||||
await avalidate_outbound_url(url)
|
||||
except UnsafeURLError as err:
|
||||
_LOGGER.warning(
|
||||
"Asset preload skipped: unsafe URL (%s)", redact_exc(err),
|
||||
)
|
||||
return
|
||||
async with sem:
|
||||
try:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
return
|
||||
data = await resp.read()
|
||||
except aiohttp.ClientError:
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
||||
return
|
||||
if max_size is not None and len(data) > max_size:
|
||||
return
|
||||
entry["data"] = data
|
||||
media.extra["playback_size"] = len(data)
|
||||
|
||||
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
|
||||
raw = await asyncio.gather(
|
||||
*(fetch(e, m) for e, m in zip(assets, media_assets)),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for r in raw:
|
||||
if isinstance(r, Exception):
|
||||
_LOGGER.warning("Asset preload raised: %s", redact_exc(r))
|
||||
|
||||
def _cache_for_entry(
|
||||
self, entry: dict[str, Any],
|
||||
) -> tuple[TelegramFileCache | None, str | None]:
|
||||
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
|
||||
|
||||
Returns (None, None) if no cache is configured or no key can be derived.
|
||||
"""
|
||||
cache_key = entry.get("cache_key")
|
||||
if cache_key:
|
||||
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
|
||||
@@ -243,6 +290,10 @@ class NotificationDispatcher:
|
||||
return self._url_cache, url
|
||||
return None, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-provider handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_telegram(
|
||||
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||
) -> dict[str, Any]:
|
||||
@@ -252,27 +303,25 @@ class NotificationDispatcher:
|
||||
max_media = target.config.get("max_media_to_send", 50)
|
||||
max_group = target.config.get("max_media_per_group", 10)
|
||||
chunk_delay = target.config.get("media_delay", 500)
|
||||
max_size = target.config.get("max_asset_size")
|
||||
if max_size:
|
||||
max_size = max_size * 1024 * 1024 # MB to bytes
|
||||
max_size_mb = target.config.get("max_asset_size")
|
||||
max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb else None
|
||||
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
|
||||
|
||||
if not bot_token:
|
||||
return {"success": False, "error": "Missing bot_token"}
|
||||
|
||||
if not target.receivers:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
# Prepare assets list once (shared across receivers)
|
||||
# Prefer internal URL for fetching (LAN speed vs public internet)
|
||||
internal_url = (target.provider_internal_url or "").rstrip("/")
|
||||
external_url = (target.provider_external_url or "").rstrip("/")
|
||||
assets = []
|
||||
media_assets: list[Any] = [] # aligned with `assets` for preload
|
||||
assets: list[dict[str, Any]] = []
|
||||
media_assets: list[Any] = []
|
||||
for asset in event.added_assets[:max_media]:
|
||||
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
||||
if not url:
|
||||
continue
|
||||
asset_entry = build_telegram_asset_entry(
|
||||
url=url or "",
|
||||
url=url,
|
||||
media_type=asset.type.value,
|
||||
api_key=target.provider_api_key,
|
||||
internal_url=internal_url,
|
||||
@@ -283,26 +332,15 @@ class NotificationDispatcher:
|
||||
assets.append(asset_entry)
|
||||
media_assets.append(asset)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
# Preload all asset bytes once so (a) TelegramClient can skip its
|
||||
# own download and (b) we know exact upload sizes in time for the
|
||||
# oversize warning in the rendered text.
|
||||
await self._preload_asset_data(assets, media_assets, session, max_size)
|
||||
default_message = self._render_message(event, target, target.locale)
|
||||
async with self._session_ctx() as session:
|
||||
await self._preload_asset_data(assets, media_assets, session, max_size_bytes)
|
||||
|
||||
# Asset cache (when in thumbhash mode) invalidates entries when the
|
||||
# asset's visual content changes. The resolver maps asset id → its
|
||||
# current thumbhash. Providers that expose thumbhash put it in
|
||||
# ``asset.extra["thumbhash"]`` (currently Immich).
|
||||
thumbhash_map = {
|
||||
asset.id: asset.extra.get("thumbhash")
|
||||
for asset in event.added_assets
|
||||
if asset.extra.get("thumbhash")
|
||||
}
|
||||
thumbhash_resolver = (
|
||||
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
||||
)
|
||||
thumbhash_resolver = thumbhash_map.get if thumbhash_map else None
|
||||
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
@@ -311,39 +349,51 @@ class NotificationDispatcher:
|
||||
thumbhash_resolver=thumbhash_resolver,
|
||||
)
|
||||
|
||||
for receiver in target.receivers:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
||||
results.append({"success": False, "error": "Invalid telegram receiver"})
|
||||
continue
|
||||
|
||||
return {"success": False, "error": "Invalid telegram receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
|
||||
text_result = await client.send_message(
|
||||
chat_id=receiver.chat_id,
|
||||
text=message,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
if not text_result.get("success"):
|
||||
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error"))
|
||||
results.append(text_result)
|
||||
continue
|
||||
_LOGGER.warning(
|
||||
"Failed to send to chat %s: %s",
|
||||
receiver.chat_id, text_result.get("error"),
|
||||
)
|
||||
return text_result
|
||||
|
||||
if assets:
|
||||
reply_to = text_result.get("message_id")
|
||||
media_result = await client.send_notification(
|
||||
chat_id=receiver.chat_id,
|
||||
assets=assets,
|
||||
reply_to_message_id=reply_to,
|
||||
reply_to_message_id=text_result.get("message_id"),
|
||||
max_group_size=max_group,
|
||||
chunk_delay=chunk_delay,
|
||||
max_asset_data_size=max_size,
|
||||
max_asset_data_size=max_size_bytes,
|
||||
send_large_photos_as_documents=send_large_as_docs,
|
||||
chat_action=chat_action or None,
|
||||
)
|
||||
if not media_result.get("success"):
|
||||
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error"))
|
||||
_LOGGER.warning(
|
||||
"Text sent OK but media failed for chat %s: %s",
|
||||
receiver.chat_id, media_result.get("error"),
|
||||
)
|
||||
# Preserve both outcomes — text succeeded, media
|
||||
# didn't. Operators losing media-failure detail
|
||||
# in the result dict made root-cause analysis
|
||||
# impossible.
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": text_result.get("message_id"),
|
||||
"media_error": media_result.get("error"),
|
||||
"media_failed_at_chunk": media_result.get("failed_at_chunk"),
|
||||
}
|
||||
return text_result
|
||||
|
||||
results.append(text_result)
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
@@ -353,17 +403,10 @@ class NotificationDispatcher:
|
||||
if not target.receivers:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
for receiver in target.receivers:
|
||||
async with self._session_ctx() as session:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||
results.append({"success": False, "error": "Invalid webhook receiver"})
|
||||
continue
|
||||
try:
|
||||
validate_outbound_url(receiver.url)
|
||||
except UnsafeURLError as err:
|
||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid webhook receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
payload = {
|
||||
"message": message,
|
||||
@@ -373,8 +416,10 @@ class NotificationDispatcher:
|
||||
"collection_id": event.collection_id,
|
||||
"timestamp": event.timestamp.isoformat(),
|
||||
}
|
||||
client = WebhookClient(session, receiver.url, receiver.headers)
|
||||
results.append(await client.send(payload))
|
||||
client = WebhookClient(session, receiver.url, safe_headers(receiver.headers))
|
||||
return await client.send(payload)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
@@ -387,7 +432,7 @@ class NotificationDispatcher:
|
||||
if not smtp_cfg.get("host"):
|
||||
return {"success": False, "error": "SMTP not configured"}
|
||||
|
||||
client = EmailClient(SmtpConfig(
|
||||
email_client = EmailClient(SmtpConfig(
|
||||
host=smtp_cfg["host"],
|
||||
port=int(smtp_cfg.get("port", 587)),
|
||||
username=smtp_cfg.get("username", ""),
|
||||
@@ -395,27 +440,28 @@ class NotificationDispatcher:
|
||||
from_address=smtp_cfg.get("from_address", ""),
|
||||
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
|
||||
use_tls=smtp_cfg.get("use_tls", True),
|
||||
tls_mode=smtp_cfg.get("tls_mode", "auto"),
|
||||
))
|
||||
|
||||
if not target.receivers:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for receiver in target.receivers:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
||||
results.append({"success": False, "error": "Invalid email receiver"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid email receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
result = await client.send(
|
||||
# body_html=None lets EmailClient build a safely-escaped HTML
|
||||
# alternative from body_text instead of trusting user content.
|
||||
return await email_client.send(
|
||||
to_email=receiver.email,
|
||||
subject=subject,
|
||||
body_text=message,
|
||||
body_html=message,
|
||||
body_html=None,
|
||||
to_name=receiver.name,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
return self._aggregate_results(results)
|
||||
|
||||
async def _send_discord(
|
||||
@@ -427,20 +473,16 @@ class NotificationDispatcher:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
username = target.config.get("username")
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
async with self._session_ctx() as session:
|
||||
client = DiscordClient(session)
|
||||
for receiver in target.receivers:
|
||||
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||
results.append({"success": False, "error": "Invalid discord receiver"})
|
||||
continue
|
||||
try:
|
||||
validate_outbound_url(receiver.webhook_url)
|
||||
except UnsafeURLError as err:
|
||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid discord receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||
return await client.send(receiver.webhook_url, message, username=username)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
@@ -453,20 +495,16 @@ class NotificationDispatcher:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
username = target.config.get("username")
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
async with self._session_ctx() as session:
|
||||
client = SlackClient(session)
|
||||
for receiver in target.receivers:
|
||||
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||
results.append({"success": False, "error": "Invalid slack receiver"})
|
||||
continue
|
||||
try:
|
||||
validate_outbound_url(receiver.webhook_url)
|
||||
except UnsafeURLError as err:
|
||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid slack receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||
return await client.send(receiver.webhook_url, message, username=username)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
@@ -480,24 +518,25 @@ class NotificationDispatcher:
|
||||
if not target.receivers:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
try:
|
||||
validate_outbound_url(server_url)
|
||||
await avalidate_outbound_url(server_url)
|
||||
except UnsafeURLError as err:
|
||||
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
|
||||
return {"success": False, "error": f"Unsafe ntfy server_url: {redact_exc(err)}"}
|
||||
|
||||
title = f"{event.event_type.value}: {event.collection_name}"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
async with self._session_ctx() as session:
|
||||
client = NtfyClient(session)
|
||||
for receiver in target.receivers:
|
||||
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||
results.append({"success": False, "error": "Invalid ntfy receiver"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid ntfy receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
results.append(await client.send(
|
||||
return await client.send(
|
||||
server_url, receiver.topic, message,
|
||||
title=title, priority=receiver.priority, auth_token=auth_token,
|
||||
))
|
||||
)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
@@ -511,35 +550,110 @@ class NotificationDispatcher:
|
||||
if not homeserver or not access_token:
|
||||
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
|
||||
try:
|
||||
validate_outbound_url(homeserver)
|
||||
await avalidate_outbound_url(homeserver)
|
||||
except UnsafeURLError as err:
|
||||
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
|
||||
return {"success": False, "error": f"Unsafe matrix homeserver_url: {redact_exc(err)}"}
|
||||
|
||||
if not target.receivers:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async with _new_session() as session:
|
||||
async with self._session_ctx() as session:
|
||||
client = MatrixClient(session, homeserver, access_token)
|
||||
for receiver in target.receivers:
|
||||
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||
results.append({"success": False, "error": "Invalid matrix receiver"})
|
||||
continue
|
||||
return {"success": False, "error": "Invalid matrix receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
results.append(await client.send_message(
|
||||
receiver.room_id, message, html_message=message,
|
||||
))
|
||||
# body_html is the same plain text — Matrix accepts the
|
||||
# raw message as both ``body`` and ``formatted_body``.
|
||||
# If templates emit HTML in the future, generate a
|
||||
# separate HTML body upstream rather than aliasing here.
|
||||
return await client.send_message(
|
||||
receiver.room_id, message, html_message=None,
|
||||
)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
|
||||
return self._aggregate_results(results)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
async def _fan_out(
|
||||
receivers: list[Receiver],
|
||||
send_one: Callable[[Receiver], Awaitable[dict[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run ``send_one`` per receiver with bounded concurrency.
|
||||
|
||||
Per-receiver exceptions are converted to failure dicts so a single
|
||||
bad receiver can't cancel its peers.
|
||||
"""
|
||||
sem = asyncio.Semaphore(_RECEIVER_CONCURRENCY)
|
||||
|
||||
async def guarded(receiver: Receiver) -> dict[str, Any]:
|
||||
async with sem:
|
||||
try:
|
||||
return await send_one(receiver)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.error("Receiver send raised: %s", redact_exc(exc))
|
||||
return {"success": False, "error": redact_exc(exc)}
|
||||
|
||||
return await asyncio.gather(*(guarded(r) for r in receivers))
|
||||
|
||||
@staticmethod
|
||||
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Aggregate broadcast results into a single result dict."""
|
||||
"""Aggregate per-receiver results into a single target-level result.
|
||||
|
||||
Preserves the per-receiver detail under ``receivers`` so a caller
|
||||
can see exactly which receivers failed, instead of getting only
|
||||
the first error.
|
||||
"""
|
||||
if not results:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
successes = sum(1 for r in results if r.get("success"))
|
||||
if successes == len(results) and results:
|
||||
return {"success": True, "receivers": len(results)}
|
||||
elif successes > 0:
|
||||
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
|
||||
elif results:
|
||||
return results[0]
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
failures = len(results) - successes
|
||||
out: dict[str, Any] = {
|
||||
"success": successes > 0,
|
||||
"receivers": len(results),
|
||||
"successes": successes,
|
||||
"failures": failures,
|
||||
"results": results,
|
||||
}
|
||||
if failures:
|
||||
out["errors"] = [
|
||||
r.get("error") for r in results if not r.get("success")
|
||||
]
|
||||
if successes == 0:
|
||||
# Surface the first error at the top level for back-compat
|
||||
# with callers that only check ``error``.
|
||||
out["error"] = results[0].get("error", "All receivers failed")
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Provider registry — replaces the if/elif chain so adding a provider
|
||||
# means just registering it here, not editing dispatch logic.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_PROVIDER_HANDLERS: dict[str, _SendMethod] = {
|
||||
"telegram": NotificationDispatcher._send_telegram,
|
||||
"webhook": NotificationDispatcher._send_webhook,
|
||||
"email": NotificationDispatcher._send_email,
|
||||
"discord": NotificationDispatcher._send_discord,
|
||||
"slack": NotificationDispatcher._send_slack,
|
||||
"ntfy": NotificationDispatcher._send_ntfy,
|
||||
"matrix": NotificationDispatcher._send_matrix,
|
||||
}
|
||||
|
||||
|
||||
def register_provider(name: str, handler: _SendMethod) -> None:
|
||||
"""Register a new dispatcher provider at runtime.
|
||||
|
||||
Allows out-of-tree providers to extend the dispatcher without
|
||||
forking. The handler must follow the
|
||||
``async (dispatcher, target, default_message, event) -> dict`` shape.
|
||||
"""
|
||||
_PROVIDER_HANDLERS[name] = handler
|
||||
|
||||
@@ -2,14 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
from email.headerregistry import Address
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
try: # Optional dependency — fail at first send rather than at import.
|
||||
import aiosmtplib
|
||||
from aiosmtplib import SMTPException
|
||||
except ImportError: # pragma: no cover
|
||||
aiosmtplib = None # type: ignore[assignment]
|
||||
|
||||
class SMTPException(Exception): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT_S: Final = 30.0
|
||||
_TlsMode = Literal["auto", "implicit", "starttls", "none"]
|
||||
# RFC 5322 lite: catches the obvious-bad addresses ("foo bar", "no-at",
|
||||
# embedded CRLF) without pretending to fully validate addresses.
|
||||
_EMAIL_RE: Final = re.compile(r"^[^\s@\r\n,;<>]+@[^\s@\r\n,;<>]+\.[^\s@\r\n,;<>]+$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmtpConfig:
|
||||
@@ -22,6 +40,55 @@ class SmtpConfig:
|
||||
from_address: str = ""
|
||||
from_name: str = "Notify Bridge"
|
||||
use_tls: bool = True
|
||||
# Explicit TLS mode. ``auto`` (back-compat) infers from ``use_tls`` and
|
||||
# ``port``: 465 → implicit; 587 with use_tls=False → starttls; 25 → none.
|
||||
tls_mode: _TlsMode = "auto"
|
||||
timeout_s: float = _DEFAULT_TIMEOUT_S
|
||||
|
||||
|
||||
def _strip_header(value: str) -> str:
|
||||
"""Reject CRLF and bare CR/LF in header-bound strings.
|
||||
|
||||
SMTP header injection turns user-controlled subject/name strings into
|
||||
arbitrary headers (``\\r\\nBcc: attacker@x``). The Python stdlib
|
||||
accepts CRLF when followed by SP/HT (header folding), so explicit
|
||||
sanitization is required even though :class:`EmailMessage` does some
|
||||
validation of its own.
|
||||
"""
|
||||
return re.sub(r"[\r\n]+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def _validate_email(addr: str) -> str:
|
||||
addr = _strip_header(addr)
|
||||
if not addr:
|
||||
raise ValueError("email address is empty")
|
||||
if not _EMAIL_RE.match(addr):
|
||||
raise ValueError("email address is invalid")
|
||||
return addr
|
||||
|
||||
|
||||
def _resolve_tls(cfg: SmtpConfig) -> tuple[bool, bool]:
|
||||
"""Resolve ``(use_tls, start_tls)`` flags from the config.
|
||||
|
||||
``tls_mode`` overrides ``use_tls``/port heuristics when provided.
|
||||
"""
|
||||
mode = cfg.tls_mode
|
||||
if mode == "implicit":
|
||||
return True, False
|
||||
if mode == "starttls":
|
||||
return False, True
|
||||
if mode == "none":
|
||||
return False, False
|
||||
# auto — preserve the historical "use_tls bool + port heuristic" behavior
|
||||
# but make the path explicit.
|
||||
if cfg.use_tls:
|
||||
return True, False
|
||||
return False, cfg.port != 25
|
||||
|
||||
|
||||
def _to_html(text: str) -> str:
|
||||
"""Convert plain text to a minimal HTML body, escaped for safety."""
|
||||
return "<html><body><pre>" + html.escape(text) + "</pre></body></html>"
|
||||
|
||||
|
||||
class EmailClient:
|
||||
@@ -30,30 +97,39 @@ class EmailClient:
|
||||
def __init__(self, smtp_config: SmtpConfig) -> None:
|
||||
self._config = smtp_config
|
||||
|
||||
@staticmethod
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
# Explicit context so the TLS posture is auditable; aiosmtplib
|
||||
# defaults look correct today but past regressions (and downstream
|
||||
# repackaging) make implicit reliance fragile.
|
||||
return ssl.create_default_context()
|
||||
|
||||
async def verify_connection(self) -> dict[str, Any]:
|
||||
"""Test SMTP connection and authentication without sending an email."""
|
||||
try:
|
||||
import aiosmtplib
|
||||
except ImportError:
|
||||
if aiosmtplib is None:
|
||||
return {"success": False, "error": "aiosmtplib not installed"}
|
||||
|
||||
cfg = self._config
|
||||
if not cfg.host:
|
||||
return {"success": False, "error": "SMTP host not configured"}
|
||||
|
||||
use_tls, start_tls = _resolve_tls(cfg)
|
||||
try:
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=cfg.host,
|
||||
port=cfg.port,
|
||||
use_tls=cfg.use_tls,
|
||||
start_tls=not cfg.use_tls and cfg.port != 25,
|
||||
use_tls=use_tls,
|
||||
start_tls=start_tls,
|
||||
tls_context=self._ssl_context(),
|
||||
timeout=cfg.timeout_s,
|
||||
validate_certs=True,
|
||||
)
|
||||
await smtp.connect()
|
||||
if cfg.username and cfg.password:
|
||||
await smtp.login(cfg.username, cfg.password)
|
||||
await smtp.quit()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
except (SMTPException, OSError) as e:
|
||||
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@@ -65,27 +141,52 @@ class EmailClient:
|
||||
body_html: str | None = None,
|
||||
to_name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
|
||||
try:
|
||||
import aiosmtplib
|
||||
except ImportError:
|
||||
"""Send an email.
|
||||
|
||||
Returns ``{"success": True}`` or ``{"success": False, "error": "..."}``.
|
||||
|
||||
``body_html`` is treated as already-safe markup. Pass ``None`` to
|
||||
derive a safe HTML alternative from ``body_text`` automatically.
|
||||
"""
|
||||
if aiosmtplib is None:
|
||||
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
|
||||
|
||||
cfg = self._config
|
||||
|
||||
if not cfg.host or not cfg.from_address:
|
||||
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
|
||||
|
||||
# Build email message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
|
||||
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
|
||||
msg["Subject"] = subject
|
||||
try:
|
||||
to_addr = _validate_email(to_email)
|
||||
from_addr = _validate_email(cfg.from_address)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": f"Invalid email address: {exc}"}
|
||||
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
# EmailMessage with structured Address objects rejects CRLF and
|
||||
# framework-folds long headers safely. We still strip first because
|
||||
# EmailMessage's display-name slot is a pure string.
|
||||
msg = EmailMessage()
|
||||
from_display = _strip_header(cfg.from_name) or ""
|
||||
to_display = _strip_header(to_name) or ""
|
||||
try:
|
||||
from_user, _, from_domain = from_addr.partition("@")
|
||||
to_user, _, to_domain = to_addr.partition("@")
|
||||
msg["From"] = Address(from_display, from_user, from_domain) if from_display else from_addr
|
||||
msg["To"] = Address(to_display, to_user, to_domain) if to_display else to_addr
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": f"Invalid email address: {exc}"}
|
||||
msg["Subject"] = _strip_header(subject)
|
||||
|
||||
msg.set_content(body_text or "", subtype="plain", charset="utf-8")
|
||||
# If the caller provided HTML explicitly, honor it; otherwise build a
|
||||
# safe escaped version so a stray "<" in the rendered template can't
|
||||
# break the markup.
|
||||
msg.add_alternative(
|
||||
body_html if body_html is not None else _to_html(body_text or ""),
|
||||
subtype="html",
|
||||
charset="utf-8",
|
||||
)
|
||||
|
||||
use_tls, start_tls = _resolve_tls(cfg)
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
@@ -93,11 +194,14 @@ class EmailClient:
|
||||
port=cfg.port,
|
||||
username=cfg.username or None,
|
||||
password=cfg.password or None,
|
||||
use_tls=cfg.use_tls,
|
||||
start_tls=not cfg.use_tls and cfg.port != 25,
|
||||
use_tls=use_tls,
|
||||
start_tls=start_tls,
|
||||
tls_context=self._ssl_context(),
|
||||
timeout=cfg.timeout_s,
|
||||
validate_certs=True,
|
||||
)
|
||||
_LOGGER.info("Email sent to %s", to_email)
|
||||
_LOGGER.info("Email sent to %s", to_addr)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
|
||||
except (SMTPException, OSError) as e:
|
||||
_LOGGER.error("Failed to send email to %s: %s", to_addr, e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Shared HTTP infrastructure for notification provider clients.
|
||||
|
||||
Slack/Discord/ntfy/Matrix/Webhook all follow the same pattern: build a
|
||||
JSON payload, POST/PUT it, decode 200-range as success, decode 4xx/5xx
|
||||
into a stable error dict, and retry transient 429/503 responses with a
|
||||
capped ``Retry-After``. ``HttpProviderClient`` centralizes that pattern
|
||||
so every provider gets the same SSRF guard, timeouts, secret-redacted
|
||||
errors, and bounded retry policy by construction — adding a new
|
||||
provider doesn't get to forget any one of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Final, Mapping
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .redact import redact, redact_exc
|
||||
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
_MAX_RETRIES: Final = 3
|
||||
_MAX_RETRY_AFTER_S: Final = 60.0
|
||||
_RETRY_STATUSES: Final = frozenset({429, 503})
|
||||
|
||||
# Hop-by-hop / framing headers a caller must not be able to override via
|
||||
# user-supplied target config. Letting them through enables request
|
||||
# smuggling, host-header bypasses of WAFs, and cache poisoning.
|
||||
_FORBIDDEN_HEADERS: Final = frozenset({
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"te",
|
||||
"upgrade",
|
||||
"expect",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
})
|
||||
|
||||
|
||||
def safe_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
||||
"""Return a copy of ``headers`` with hop-by-hop/forbidden names dropped
|
||||
and CRLF-bearing values rejected.
|
||||
|
||||
A target config that lets a user inject ``"X-Foo": "bar\\r\\nHost: evil"``
|
||||
can perform request smuggling depending on aiohttp's framing. We strip
|
||||
those values rather than letting them reach the wire.
|
||||
"""
|
||||
if not headers:
|
||||
return {}
|
||||
safe: dict[str, str] = {}
|
||||
for raw_name, raw_value in headers.items():
|
||||
name = str(raw_name).strip()
|
||||
if not name or name.lower() in _FORBIDDEN_HEADERS:
|
||||
continue
|
||||
if any(c in name for c in "\r\n:"):
|
||||
continue
|
||||
value = str(raw_value)
|
||||
if "\r" in value or "\n" in value:
|
||||
continue
|
||||
safe[name] = value
|
||||
return safe
|
||||
|
||||
|
||||
def make_error(message: str, *, status: int | None = None, body: str | None = None) -> dict[str, Any]:
|
||||
"""Build a stable failure dict shape used by every provider client."""
|
||||
err: dict[str, Any] = {"success": False, "error": redact(message)}
|
||||
if status is not None:
|
||||
err["status_code"] = status
|
||||
if body:
|
||||
err["body"] = redact(body)[:200]
|
||||
return err
|
||||
|
||||
|
||||
def make_success(**extra: Any) -> dict[str, Any]:
|
||||
"""Build a stable success dict shape used by every provider client."""
|
||||
out: dict[str, Any] = {"success": True}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def _retry_after_seconds(headers: Mapping[str, str], cap_s: float) -> float:
|
||||
raw = headers.get("Retry-After") or headers.get("retry-after") or "2"
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
seconds = 2.0
|
||||
return max(0.0, min(seconds, cap_s))
|
||||
|
||||
|
||||
class HttpProviderClient:
|
||||
"""Base for JSON-over-HTTP notification providers.
|
||||
|
||||
Subclasses call :meth:`request` instead of using ``self._session``
|
||||
directly. ``request`` runs the SSRF guard (skippable for known-safe
|
||||
upstreams via ``ssrf_validate=False``), enforces a per-request
|
||||
timeout, retries 429/503 with a capped ``Retry-After``, and turns
|
||||
transport/HTTP errors into the canonical ``{"success": False, ...}``
|
||||
shape with secrets redacted.
|
||||
"""
|
||||
|
||||
_max_retries: int = _MAX_RETRIES
|
||||
# Settable per-instance so tests / hostile-upstream tuning can
|
||||
# tighten the cap. Reads of this attribute fall through to the
|
||||
# class default when no instance value has been set.
|
||||
_MAX_RETRY_AFTER: float = _MAX_RETRY_AFTER_S
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
*,
|
||||
timeout: aiohttp.ClientTimeout | None = None,
|
||||
provider_name: str = "http",
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._timeout = timeout or _DEFAULT_TIMEOUT
|
||||
self._provider = provider_name
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
json: Any = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
ssrf_validate: bool = True,
|
||||
retry_statuses: frozenset[int] = _RETRY_STATUSES,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a single request with retry + redaction. Always returns a dict.
|
||||
|
||||
On 2xx returns ``{"success": True, "status_code": int, "json": ...
|
||||
OR "body": str}``. On non-2xx returns the canonical error dict.
|
||||
"""
|
||||
if ssrf_validate:
|
||||
try:
|
||||
await avalidate_outbound_url(url)
|
||||
except UnsafeURLError as err:
|
||||
return make_error(f"Unsafe URL: {redact_exc(err)}")
|
||||
|
||||
outbound_headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
outbound_headers.update(safe_headers(headers))
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
headers=outbound_headers,
|
||||
timeout=self._timeout,
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
if resp.status in retry_statuses and attempt < self._max_retries:
|
||||
delay = _retry_after_seconds(resp.headers, self._MAX_RETRY_AFTER)
|
||||
_LOGGER.warning(
|
||||
"%s %s %s: HTTP %d, retrying after %.2fs (attempt %d/%d)",
|
||||
self._provider, method, redact(url), resp.status,
|
||||
delay, attempt, self._max_retries,
|
||||
)
|
||||
await resp.read() # drain body so connection can return to pool
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
if 200 <= resp.status < 300:
|
||||
try:
|
||||
payload: Any = await resp.json(content_type=None)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
payload = await resp.text()
|
||||
return make_success(status_code=resp.status, json=payload)
|
||||
body = await resp.text()
|
||||
return make_error(
|
||||
f"HTTP {resp.status}",
|
||||
status=resp.status,
|
||||
body=body,
|
||||
)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||
# asyncio.CancelledError inherits from BaseException on
|
||||
# 3.8+, so it is not caught here — good: cancellation
|
||||
# must propagate.
|
||||
if attempt < self._max_retries and isinstance(err, asyncio.TimeoutError):
|
||||
_LOGGER.warning(
|
||||
"%s %s %s: timeout, retrying (attempt %d/%d)",
|
||||
self._provider, method, redact(url),
|
||||
attempt, self._max_retries,
|
||||
)
|
||||
await asyncio.sleep(min(2 ** (attempt - 1), 5))
|
||||
continue
|
||||
return make_error(redact_exc(err))
|
||||
|
||||
# Retry budget exhausted on a retriable status.
|
||||
return make_error("Rate limited (retries exhausted)")
|
||||
@@ -2,22 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Final
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..http_base import _MAX_RETRY_AFTER_S, safe_headers
|
||||
from ..redact import redact, redact_exc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Monotonically increasing transaction counter for idempotent sends
|
||||
_txn_counter = int(time.time() * 1000)
|
||||
# Matrix room IDs are ``!opaque:server.name`` per the spec. We also allow
|
||||
# the ``#alias:server`` form because some callers may pass aliases. The
|
||||
# pattern's purpose is to reject obvious path-injection (``/``, ``..``,
|
||||
# control chars, query/fragment chars) before the value reaches a URL.
|
||||
_ROOM_ID_RE: Final = re.compile(r"^[!#][^\x00-\x1f\s/?#]{1,255}:[A-Za-z0-9.\-:]{1,255}$")
|
||||
|
||||
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
_MAX_RETRIES: Final = 3
|
||||
|
||||
|
||||
def _next_txn_id() -> str:
|
||||
global _txn_counter
|
||||
_txn_counter += 1
|
||||
return str(_txn_counter)
|
||||
def _validate_room_id(room_id: str) -> str:
|
||||
if not room_id:
|
||||
raise ValueError("room_id is empty")
|
||||
if not _ROOM_ID_RE.match(room_id):
|
||||
raise ValueError("room_id format is invalid")
|
||||
return room_id
|
||||
|
||||
|
||||
class MatrixClient:
|
||||
@@ -33,47 +47,67 @@ class MatrixClient:
|
||||
self._homeserver = homeserver_url.rstrip("/")
|
||||
self._token = access_token
|
||||
|
||||
@staticmethod
|
||||
def _txn_id() -> str:
|
||||
# uuid4 hex is collision-resistant across processes/restarts;
|
||||
# eliminates the previous module-level counter race.
|
||||
return uuid.uuid4().hex
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
room_id: str,
|
||||
message: str,
|
||||
html_message: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a text message to a Matrix room.
|
||||
"""Send a text message to a Matrix room."""
|
||||
try:
|
||||
room_id = _validate_room_id(room_id)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": f"Invalid room_id: {exc}"}
|
||||
|
||||
Args:
|
||||
room_id: Internal room ID (e.g. !abc:matrix.org)
|
||||
message: Plain text body
|
||||
html_message: Optional HTML-formatted body
|
||||
"""
|
||||
if not room_id:
|
||||
return {"success": False, "error": "Missing room_id"}
|
||||
encoded_room = quote(room_id, safe="")
|
||||
url = (
|
||||
f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}"
|
||||
f"/send/m.room.message/{self._txn_id()}"
|
||||
)
|
||||
|
||||
txn_id = _next_txn_id()
|
||||
# URL-encode the room_id (! and : need encoding)
|
||||
encoded_room = room_id.replace("!", "%21").replace(":", "%3A")
|
||||
url = f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": message,
|
||||
}
|
||||
body: dict[str, Any] = {"msgtype": "m.text", "body": message}
|
||||
if html_message:
|
||||
body["format"] = "org.matrix.custom.html"
|
||||
body["formatted_body"] = html_message
|
||||
|
||||
headers = {
|
||||
headers = safe_headers({
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
async with self._session.put(url, json=body, headers=headers) as resp:
|
||||
if 200 <= resp.status < 300:
|
||||
return {"success": True}
|
||||
resp_body = await resp.text()
|
||||
if resp.status == 429:
|
||||
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200])
|
||||
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
async with self._session.put(
|
||||
url, json=body, headers=headers,
|
||||
timeout=_DEFAULT_TIMEOUT, allow_redirects=False,
|
||||
) as resp:
|
||||
if 200 <= resp.status < 300:
|
||||
return {"success": True}
|
||||
resp_body = await resp.text()
|
||||
if resp.status == 429 and attempt < _MAX_RETRIES:
|
||||
try:
|
||||
wait_s = float(resp.headers.get("Retry-After", "2"))
|
||||
except (TypeError, ValueError):
|
||||
wait_s = 2.0
|
||||
wait_s = max(0.0, min(wait_s, _MAX_RETRY_AFTER_S))
|
||||
_LOGGER.warning(
|
||||
"Matrix rate limited, retrying after %.2fs (attempt %d/%d)",
|
||||
wait_s, attempt, _MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_s)
|
||||
continue
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"HTTP {resp.status}: {redact(resp_body)[:200]}",
|
||||
"status_code": resp.status,
|
||||
}
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
|
||||
return {"success": False, "error": redact_exc(e)}
|
||||
|
||||
return {"success": False, "error": "Rate limited (retries exhausted)"}
|
||||
|
||||
@@ -3,18 +3,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..http_base import HttpProviderClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_PRIORITY_MIN: Final = 1
|
||||
_PRIORITY_MAX: Final = 5
|
||||
_DEFAULT_PRIORITY: Final = 3
|
||||
_MAX_TAGS: Final = 10
|
||||
_MAX_TAG_LEN: Final = 64
|
||||
|
||||
class NtfyClient:
|
||||
|
||||
def _strip_crlf(value: str) -> str:
|
||||
"""Remove CR/LF — ntfy's JSON path is safe today, but the same fields
|
||||
are used by the header API; defensive sanitization here means a future
|
||||
refactor can't accidentally re-introduce header injection."""
|
||||
return value.replace("\r", " ").replace("\n", " ")
|
||||
|
||||
|
||||
class NtfyClient(HttpProviderClient):
|
||||
"""Sends push notifications via ntfy server."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||
self._session = session
|
||||
super().__init__(session, provider_name="ntfy")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -22,39 +37,48 @@ class NtfyClient:
|
||||
topic: str,
|
||||
message: str,
|
||||
title: str | None = None,
|
||||
priority: int = 3,
|
||||
priority: int = _DEFAULT_PRIORITY,
|
||||
tags: list[str] | None = None,
|
||||
click_url: str | None = None,
|
||||
auth_token: str | None = None,
|
||||
markdown: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a push notification to an ntfy topic."""
|
||||
if not server_url or not topic:
|
||||
return {"success": False, "error": "Missing server_url or topic"}
|
||||
|
||||
url = f"{server_url.rstrip('/')}"
|
||||
topic = _strip_crlf(topic).strip()
|
||||
if not topic:
|
||||
return {"success": False, "error": "Topic is empty after sanitization"}
|
||||
|
||||
try:
|
||||
priority_int = int(priority) if priority is not None else _DEFAULT_PRIORITY
|
||||
except (TypeError, ValueError):
|
||||
priority_int = _DEFAULT_PRIORITY
|
||||
priority_int = max(_PRIORITY_MIN, min(priority_int, _PRIORITY_MAX))
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"topic": topic,
|
||||
"message": message,
|
||||
"markdown": True,
|
||||
"markdown": bool(markdown),
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if priority != 3:
|
||||
payload["priority"] = priority
|
||||
payload["title"] = _strip_crlf(title)
|
||||
if priority_int != _DEFAULT_PRIORITY:
|
||||
payload["priority"] = priority_int
|
||||
if tags:
|
||||
payload["tags"] = tags
|
||||
cleaned = [
|
||||
_strip_crlf(str(t))[:_MAX_TAG_LEN]
|
||||
for t in tags[:_MAX_TAGS]
|
||||
if t
|
||||
]
|
||||
if cleaned:
|
||||
payload["tags"] = cleaned
|
||||
if click_url:
|
||||
payload["click"] = click_url
|
||||
payload["click"] = _strip_crlf(click_url)
|
||||
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
headers: dict[str, str] = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
try:
|
||||
async with self._session.post(url, json=payload, headers=headers) as resp:
|
||||
if 200 <= resp.status < 300:
|
||||
return {"success": True}
|
||||
body = await resp.text()
|
||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
return await self.request("POST", server_url.rstrip("/"), json=payload, headers=headers)
|
||||
|
||||
@@ -2,47 +2,88 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from notify_bridge_core.storage import StorageBackend
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bound on queue length. Without a cap, a misconfigured quiet-hour
|
||||
# window plus high event throughput grows the persisted file unboundedly
|
||||
# and every enqueue rewrites the whole file (O(n²) total writes). When
|
||||
# the cap is hit we drop the oldest entry (FIFO) so the most recent
|
||||
# events still reach the recipient when the window opens.
|
||||
DEFAULT_MAX_QUEUE_SIZE: Final = 1000
|
||||
|
||||
|
||||
class NotificationQueue:
|
||||
"""Persistent queue for notifications deferred during quiet hours."""
|
||||
|
||||
def __init__(self, backend: StorageBackend) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
backend: StorageBackend,
|
||||
*,
|
||||
max_size: int = DEFAULT_MAX_QUEUE_SIZE,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._max_size = max_size
|
||||
# Coordinates load / enqueue / clear / remove so a write-while-load
|
||||
# race can't leave the in-memory copy out of sync with disk and so
|
||||
# bulk operations don't interleave their reads-then-writes.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _ensure_schema(data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("queue"), list):
|
||||
return {"queue": []}
|
||||
return data
|
||||
|
||||
async def async_load(self) -> None:
|
||||
self._data = await self._backend.load() or {"queue": []}
|
||||
async with self._lock:
|
||||
raw = await self._backend.load()
|
||||
self._data = self._ensure_schema(raw)
|
||||
|
||||
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
|
||||
if self._data is None:
|
||||
self._data = {"queue": []}
|
||||
self._data["queue"].append({
|
||||
"params": notification_params,
|
||||
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
await self._backend.save(self._data)
|
||||
async with self._lock:
|
||||
if self._data is None:
|
||||
self._data = {"queue": []}
|
||||
queue: list[dict[str, Any]] = self._data["queue"]
|
||||
queue.append({
|
||||
"params": notification_params,
|
||||
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
if self._max_size > 0 and len(queue) > self._max_size:
|
||||
# Drop oldest (FIFO) so a new event can still land.
|
||||
drop = len(queue) - self._max_size
|
||||
_LOGGER.warning(
|
||||
"NotificationQueue: dropping %d oldest entries (cap=%d)",
|
||||
drop, self._max_size,
|
||||
)
|
||||
del queue[:drop]
|
||||
await self._backend.save(self._data)
|
||||
|
||||
def get_all(self) -> list[dict[str, Any]]:
|
||||
if not self._data:
|
||||
return []
|
||||
return list(self._data.get("queue", []))
|
||||
# Deep copy so callers can iterate / mutate without corrupting the
|
||||
# in-memory queue. The cost is bounded by ``max_size``.
|
||||
return copy.deepcopy(list(self._data.get("queue", [])))
|
||||
|
||||
def has_pending(self) -> bool:
|
||||
return bool(self._data and self._data.get("queue"))
|
||||
|
||||
async def async_clear(self) -> None:
|
||||
if self._data:
|
||||
self._data["queue"] = []
|
||||
await self._backend.save(self._data)
|
||||
async with self._lock:
|
||||
if self._data:
|
||||
self._data["queue"] = []
|
||||
await self._backend.save(self._data)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
async with self._lock:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,51 +70,64 @@ class MatrixReceiver(Receiver):
|
||||
room_id: str = ""
|
||||
|
||||
|
||||
_ReceiverFactory = Callable[[str, dict[str, Any]], Receiver]
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
_RECEIVER_FACTORIES: dict[str, _ReceiverFactory] = {
|
||||
"telegram": lambda locale, config: TelegramReceiver(
|
||||
locale=locale, config=config, chat_id=str(config.get("chat_id", "")),
|
||||
),
|
||||
"webhook": lambda locale, config: WebhookReceiver(
|
||||
locale=locale, config=config,
|
||||
url=str(config.get("url", "")),
|
||||
headers=dict(config.get("headers", {}) or {}),
|
||||
),
|
||||
"email": lambda locale, config: EmailReceiver(
|
||||
locale=locale, config=config,
|
||||
email=str(config.get("email", "")),
|
||||
name=str(config.get("name", "")),
|
||||
),
|
||||
"discord": lambda locale, config: DiscordReceiver(
|
||||
locale=locale, config=config,
|
||||
webhook_url=str(config.get("webhook_url", "")),
|
||||
),
|
||||
"slack": lambda locale, config: SlackReceiver(
|
||||
locale=locale, config=config,
|
||||
webhook_url=str(config.get("webhook_url", "")),
|
||||
),
|
||||
"ntfy": lambda locale, config: NtfyReceiver(
|
||||
locale=locale, config=config,
|
||||
topic=str(config.get("topic", "")),
|
||||
priority=_coerce_int(config.get("priority"), 3),
|
||||
),
|
||||
"matrix": lambda locale, config: MatrixReceiver(
|
||||
locale=locale, config=config,
|
||||
room_id=str(config.get("room_id", "")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def register_receiver_factory(target_type: str, factory: _ReceiverFactory) -> None:
|
||||
"""Register a receiver factory for an out-of-tree target type."""
|
||||
_RECEIVER_FACTORIES[target_type] = factory
|
||||
|
||||
|
||||
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
|
||||
"""Factory: build typed Receiver from target type and config dict."""
|
||||
if target_type == "telegram":
|
||||
return TelegramReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
chat_id=str(config.get("chat_id", "")),
|
||||
)
|
||||
if target_type == "webhook":
|
||||
return WebhookReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
)
|
||||
if target_type == "email":
|
||||
return EmailReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
email=config.get("email", ""),
|
||||
name=config.get("name", ""),
|
||||
)
|
||||
if target_type == "discord":
|
||||
return DiscordReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
webhook_url=config.get("webhook_url", ""),
|
||||
)
|
||||
if target_type == "slack":
|
||||
return SlackReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
webhook_url=config.get("webhook_url", ""),
|
||||
)
|
||||
if target_type == "ntfy":
|
||||
return NtfyReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
topic=config.get("topic", ""),
|
||||
priority=config.get("priority", 3),
|
||||
)
|
||||
if target_type == "matrix":
|
||||
return MatrixReceiver(
|
||||
locale=locale,
|
||||
config=config,
|
||||
room_id=config.get("room_id", ""),
|
||||
)
|
||||
return Receiver(locale=locale, config=config)
|
||||
"""Factory: build typed Receiver from target type and config dict.
|
||||
|
||||
Falls back to a base ``Receiver`` for unknown target types so callers
|
||||
that handle types defensively still receive a usable object — but the
|
||||
dispatcher rejects them with ``"Unknown target type"`` so a typo can't
|
||||
silently route to nowhere.
|
||||
"""
|
||||
factory = _RECEIVER_FACTORIES.get(target_type)
|
||||
if factory is None:
|
||||
return Receiver(locale=locale, config=config)
|
||||
return factory(locale, config)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Secret-redaction helpers for log lines and error strings.
|
||||
|
||||
Notification clients embed secrets in URLs (Telegram bot tokens) and
|
||||
Authorization headers (Matrix access tokens, ntfy bearer tokens). When
|
||||
those secrets surface in ``aiohttp.ClientError.__str__``, response
|
||||
bodies, or operator-visible error fields, they leak into logs and into
|
||||
the per-target result dict that callers may forward upstream. ``redact``
|
||||
returns a defanged copy safe for both contexts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
# api.telegram.org/bot<digits>:<token>/<method>
|
||||
_TELEGRAM_BOT_TOKEN_RE: Final = re.compile(
|
||||
r"(api\.telegram\.org/bot)\d+:[A-Za-z0-9_-]+", re.IGNORECASE,
|
||||
)
|
||||
# Authorization: Bearer <token> (header form, case-insensitive)
|
||||
_BEARER_RE: Final = re.compile(r"(Bearer\s+)[A-Za-z0-9._\-+/=]+", re.IGNORECASE)
|
||||
# Discord webhook: /api/webhooks/<id>/<token>
|
||||
_DISCORD_WEBHOOK_RE: Final = re.compile(
|
||||
r"(discord(?:app)?\.com/api/webhooks/\d+/)[A-Za-z0-9_-]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Slack webhook path: /services/T.../B.../<token>
|
||||
_SLACK_WEBHOOK_RE: Final = re.compile(
|
||||
r"(hooks\.slack\.com/services/[A-Z0-9]+/[A-Z0-9]+/)[A-Za-z0-9]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# URL userinfo: scheme://user:password@host
|
||||
_URL_USERINFO_RE: Final = re.compile(
|
||||
r"([a-z][a-z0-9+\-.]*://)[^/@\s]+:[^/@\s]+@",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Common token query parameters
|
||||
_QUERY_TOKEN_RE: Final = re.compile(
|
||||
r"([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
"""Return ``text`` with known secret patterns replaced by ``***``.
|
||||
|
||||
Idempotent and safe to call on already-redacted strings. Always
|
||||
returns a ``str``; non-strings are coerced via ``str()`` so callers
|
||||
can pass exception instances directly.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
text = _TELEGRAM_BOT_TOKEN_RE.sub(r"\1***", text)
|
||||
text = _DISCORD_WEBHOOK_RE.sub(r"\1***", text)
|
||||
text = _SLACK_WEBHOOK_RE.sub(r"\1***", text)
|
||||
text = _BEARER_RE.sub(r"\1***", text)
|
||||
text = _URL_USERINFO_RE.sub(r"\1***@", text)
|
||||
text = _QUERY_TOKEN_RE.sub(r"\1***", text)
|
||||
return text
|
||||
|
||||
|
||||
def redact_exc(err: BaseException) -> str:
|
||||
"""Redact-and-stringify an exception. Convenience for error fields."""
|
||||
return redact(str(err))
|
||||
@@ -7,14 +7,16 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..http_base import HttpProviderClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackClient:
|
||||
class SlackClient(HttpProviderClient):
|
||||
"""Sends messages via Slack incoming webhook URLs."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||
self._session = session
|
||||
super().__init__(session, provider_name="slack")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -33,18 +35,4 @@ class SlackClient:
|
||||
if icon_emoji:
|
||||
payload["icon_emoji"] = icon_emoji
|
||||
|
||||
try:
|
||||
async with self._session.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as resp:
|
||||
if resp.status == 429:
|
||||
_LOGGER.warning("Slack rate limited")
|
||||
return {"success": False, "error": "Rate limited by Slack"}
|
||||
if 200 <= resp.status < 300:
|
||||
return {"success": True}
|
||||
body = await resp.text()
|
||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
return await self.request("POST", webhook_url, json=payload)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""Outbound URL validation to mitigate SSRF attacks.
|
||||
|
||||
User-controlled URLs (provider `url`, webhook target `url`, shared-link
|
||||
base URLs, image downloads) must be validated before any HTTP request is
|
||||
issued. This module rejects schemes other than http/https and blocks
|
||||
destinations that resolve to private, loopback, link-local, or unspecified
|
||||
address ranges.
|
||||
User-controlled URLs (provider ``url``, webhook target ``url``,
|
||||
shared-link base URLs, image downloads) must be validated before any
|
||||
HTTP request is issued. This module rejects schemes other than
|
||||
http/https and blocks destinations that resolve to private, loopback,
|
||||
link-local, unspecified, CGNAT (100.64.0.0/10), or IPv4-mapped IPv6
|
||||
ranges.
|
||||
|
||||
DNS rebinding mitigation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
``avalidate_outbound_url`` returns the original URL on success, but
|
||||
also returns the resolved IP it actually validated. Callers that pass
|
||||
the validated URL straight into ``aiohttp`` are vulnerable to a
|
||||
DNS-rebinding attack: the validator's ``getaddrinfo`` returns a public
|
||||
IP; aiohttp's connect-time resolution returns ``127.0.0.1``. To close
|
||||
that gap, use :func:`build_ssrf_safe_session` (or
|
||||
:class:`PinnedResolver`) so the resolved IP from the validation step is
|
||||
the one aiohttp connects to.
|
||||
|
||||
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
|
||||
development against localhost services.
|
||||
@@ -12,20 +24,61 @@ development against localhost services.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
||||
_ALLOWED_SCHEMES = {"http", "https"}
|
||||
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
||||
|
||||
# Carrier-grade NAT range. Not in stdlib's ``is_private``; an attacker
|
||||
# pointing a domain at a CGNAT IP could reach the operator's ISP-side
|
||||
# routing infrastructure. RFC 6598.
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
|
||||
_LOGGER.warning(
|
||||
"SSRF guard: private-URL bypass ENABLED "
|
||||
"(NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1). Requests to RFC1918 / "
|
||||
"loopback / link-local hosts will be permitted."
|
||||
)
|
||||
|
||||
|
||||
class UnsafeURLError(ValueError):
|
||||
"""Raised when a URL targets a disallowed network destination."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedURL:
|
||||
"""Result of validating an outbound URL.
|
||||
|
||||
Attributes:
|
||||
url: The original URL string (unchanged).
|
||||
host: Hostname extracted from the URL (lower-cased, IDN-encoded).
|
||||
ip: Resolved IP address that passed the block-range check, as a
|
||||
string. Pass to :class:`PinnedResolver` to defeat DNS
|
||||
rebinding by reusing this exact IP at connect time.
|
||||
"""
|
||||
|
||||
url: str
|
||||
host: str
|
||||
ip: str
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
# An IPv4-mapped IPv6 like ``::ffff:127.0.0.1`` is NOT considered
|
||||
# ``is_private`` etc. by stdlib — the v4 view holds those flags. So
|
||||
# we unwrap before checking.
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
@@ -33,42 +86,54 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
or (isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK)
|
||||
)
|
||||
|
||||
|
||||
def validate_outbound_url(url: str) -> str:
|
||||
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
||||
def _safe_host_repr(host: str) -> str:
|
||||
"""Return ``host`` shortened/escaped for safe inclusion in error text."""
|
||||
h = host[:64].replace("\r", "").replace("\n", "")
|
||||
return h
|
||||
|
||||
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
|
||||
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
|
||||
private addresses are permitted but the scheme check still applies.
|
||||
"""
|
||||
|
||||
def _normalize_host(parsed_host: str) -> str:
|
||||
"""Normalize a hostname: lowercase, strip trailing dot, IDN-encode."""
|
||||
host = parsed_host.lower()
|
||||
if host.endswith("."):
|
||||
host = host[:-1]
|
||||
# Strip IPv6 zone id ("fe80::1%eth0") — must not reach the resolver.
|
||||
if "%" in host:
|
||||
host = host.split("%", 1)[0]
|
||||
# IDN-encode unicode hostnames so we don't downgrade to confusables
|
||||
# in any later log/output and so getaddrinfo gets the ascii form.
|
||||
try:
|
||||
if any(ord(c) > 127 for c in host):
|
||||
host = host.encode("idna").decode("ascii")
|
||||
except UnicodeError:
|
||||
# Caller will fail on resolution; leave as-is so the error path
|
||||
# surfaces a "DNS resolution failed" rather than a stack trace.
|
||||
pass
|
||||
return host
|
||||
|
||||
|
||||
def _check_scheme_host(url: str) -> tuple[str, str]:
|
||||
if not isinstance(url, str) or not url:
|
||||
raise UnsafeURLError("URL is empty")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in _ALLOWED_SCHEMES:
|
||||
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in _ALLOWED_SCHEMES:
|
||||
raise UnsafeURLError(f"Scheme '{scheme[:16]}' not allowed")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise UnsafeURLError("URL has no host")
|
||||
return scheme, _normalize_host(host)
|
||||
|
||||
if _ALLOW_PRIVATE:
|
||||
return url
|
||||
|
||||
# Literal IP host
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_blocked_ip(ip):
|
||||
raise UnsafeURLError(f"Host {host} is in a blocked range")
|
||||
return url
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Hostname — resolve and reject if any resolution is in a blocked range.
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror as exc:
|
||||
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
||||
def _select_addresses(
|
||||
host: str, infos: list[tuple],
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
"""Return parsed, non-blocked IPs from ``getaddrinfo`` results."""
|
||||
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
try:
|
||||
@@ -76,5 +141,143 @@ def validate_outbound_url(url: str) -> str:
|
||||
except ValueError:
|
||||
continue
|
||||
if _is_blocked_ip(ip):
|
||||
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
|
||||
raise UnsafeURLError(
|
||||
f"Host {_safe_host_repr(host)} resolves to blocked address {ip}"
|
||||
)
|
||||
addrs.append(ip)
|
||||
if not addrs:
|
||||
raise UnsafeURLError(f"Host {_safe_host_repr(host)} has no usable address")
|
||||
return addrs
|
||||
|
||||
|
||||
def validate_outbound_url(url: str) -> str:
|
||||
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
||||
|
||||
.. deprecated::
|
||||
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
|
||||
:func:`avalidate_outbound_url` from async code paths so the
|
||||
event loop isn't blocked, and use :func:`build_ssrf_safe_session`
|
||||
to defeat DNS rebinding.
|
||||
"""
|
||||
_, host = _check_scheme_host(url)
|
||||
|
||||
if _ALLOW_PRIVATE:
|
||||
return url
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_blocked_ip(ip):
|
||||
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
|
||||
return url
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except (socket.gaierror, UnicodeError, OSError) as exc:
|
||||
# ``UnicodeError`` covers IDNA failures (labels >63 chars, malformed
|
||||
# unicode) which getaddrinfo surfaces as encoding errors rather than
|
||||
# gaierror. ``OSError`` covers transient resolver failures on some
|
||||
# platforms.
|
||||
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
|
||||
_select_addresses(host, infos)
|
||||
return url
|
||||
|
||||
|
||||
async def avalidate_outbound_url(url: str) -> str:
|
||||
"""Async variant — returns the URL on success.
|
||||
|
||||
For DNS-rebinding-safe usage, prefer :func:`avalidate_outbound_url_full`
|
||||
which also returns the resolved IP for connect-time pinning.
|
||||
"""
|
||||
result = await avalidate_outbound_url_full(url)
|
||||
return result.url
|
||||
|
||||
|
||||
async def avalidate_outbound_url_full(url: str) -> ValidatedURL:
|
||||
"""Validate ``url`` and return a :class:`ValidatedURL` on success.
|
||||
|
||||
The returned ``ip`` field is the IP that passed the block-range
|
||||
check. Pair with :class:`PinnedResolver` so aiohttp connects to that
|
||||
exact IP and a malicious DNS server can't swap in a private address
|
||||
after validation.
|
||||
"""
|
||||
_, host = _check_scheme_host(url)
|
||||
|
||||
if _ALLOW_PRIVATE:
|
||||
# In dev mode we still resolve to give a usable IP, but we don't
|
||||
# gate on the result.
|
||||
try:
|
||||
ip = str(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
infos = await loop.getaddrinfo(host, None)
|
||||
ip = infos[0][4][0] if infos else host
|
||||
except (socket.gaierror, OSError):
|
||||
ip = host
|
||||
return ValidatedURL(url=url, host=host, ip=ip)
|
||||
|
||||
# Literal IP host
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(host)
|
||||
if _is_blocked_ip(ip_obj):
|
||||
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
|
||||
return ValidatedURL(url=url, host=host, ip=str(ip_obj))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
infos = await loop.getaddrinfo(host, None)
|
||||
except (socket.gaierror, UnicodeError, OSError) as exc:
|
||||
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
|
||||
addrs = _select_addresses(host, infos)
|
||||
return ValidatedURL(url=url, host=host, ip=str(addrs[0]))
|
||||
|
||||
|
||||
class PinnedResolver(aiohttp.abc.AbstractResolver):
|
||||
"""aiohttp resolver that returns a fixed (host, ip) mapping.
|
||||
|
||||
Used to pin the resolved IP from :func:`avalidate_outbound_url_full`
|
||||
so aiohttp's connect-time resolution can't be tricked by DNS
|
||||
rebinding into using a different IP than the one we validated.
|
||||
|
||||
Falls back to :class:`aiohttp.AsyncResolver` (or default) for any
|
||||
host not explicitly pinned, so a single resolver instance can be
|
||||
reused across multiple validated URLs.
|
||||
"""
|
||||
|
||||
def __init__(self, mapping: dict[str, str] | None = None) -> None:
|
||||
self._map: dict[str, str] = dict(mapping or {})
|
||||
self._fallback: aiohttp.abc.AbstractResolver | None = None
|
||||
|
||||
def pin(self, host: str, ip: str) -> None:
|
||||
self._map[host.lower()] = ip
|
||||
|
||||
async def resolve(
|
||||
self, host: str, port: int = 0, family: int = socket.AF_INET,
|
||||
) -> list[dict]:
|
||||
ip = self._map.get(host.lower())
|
||||
if ip is not None:
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
ip_obj = None
|
||||
if ip_obj is not None:
|
||||
fam = socket.AF_INET6 if ip_obj.version == 6 else socket.AF_INET
|
||||
return [{
|
||||
"hostname": host,
|
||||
"host": ip,
|
||||
"port": port,
|
||||
"family": fam,
|
||||
"proto": 0,
|
||||
"flags": socket.AI_NUMERICHOST,
|
||||
}]
|
||||
if self._fallback is None:
|
||||
self._fallback = aiohttp.ThreadedResolver()
|
||||
return await self._fallback.resolve(host, port, family)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._fallback is not None:
|
||||
await self._fallback.close()
|
||||
|
||||
@@ -2,16 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from notify_bridge_core.storage import StorageBackend
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||
DEFAULT_MAX_ENTRIES = 5000
|
||||
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
|
||||
DEFAULT_MAX_ENTRIES: Final = 5000
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO-8601 timestamp tolerantly. Returns ``None`` on failure."""
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
# Python <3.11 doesn't accept "Z"; normalize to +00:00.
|
||||
v = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
return datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class TelegramFileCache:
|
||||
@@ -25,7 +38,17 @@ class TelegramFileCache:
|
||||
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||
should be triggered by visual change, not elapsed time.
|
||||
|
||||
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
||||
``max_entries`` always applies as a FIFO size cap (oldest-cached first).
|
||||
|
||||
Concurrency
|
||||
~~~~~~~~~~~
|
||||
All mutators take an internal ``asyncio.Lock`` so concurrent
|
||||
media-group sends can't interleave a read-time invalidation with a
|
||||
bulk write and corrupt the underlying dict (``RuntimeError:
|
||||
dictionary changed size during iteration``) or lose just-written
|
||||
entries. Reads do not take the lock — they are O(1) dict lookups —
|
||||
but ``get`` uses a snapshot reference so it cannot mutate the data
|
||||
structure under another task.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -40,35 +63,40 @@ class TelegramFileCache:
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._use_thumbhash = use_thumbhash
|
||||
self._max_entries = max_entries
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_load(self) -> None:
|
||||
self._data = await self._backend.load() or {"files": {}}
|
||||
await self._cleanup_expired()
|
||||
async with self._lock:
|
||||
self._data = await self._backend.load() or {"files": {}}
|
||||
await self._cleanup_expired_locked()
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
async def _cleanup_expired_locked(self) -> None:
|
||||
"""Caller must hold ``self._lock``."""
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
files = self._data["files"]
|
||||
files: dict[str, dict[str, Any]] = self._data["files"]
|
||||
changed = False
|
||||
|
||||
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
||||
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
||||
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
||||
# cache forever, subject only to the size cap.
|
||||
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [
|
||||
url for url, entry in files.items()
|
||||
if entry.get("cached_at") and
|
||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||
]
|
||||
expired: list[str] = []
|
||||
for url, entry in list(files.items()):
|
||||
cached_at = _parse_iso(entry.get("cached_at"))
|
||||
if cached_at is None:
|
||||
continue
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
if (now - cached_at).total_seconds() > self._ttl_seconds:
|
||||
expired.append(url)
|
||||
for key in expired:
|
||||
del files[key]
|
||||
changed = True
|
||||
|
||||
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
||||
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||
sorted_keys = sorted(
|
||||
files,
|
||||
key=lambda k: _parse_iso(files[k].get("cached_at")) or datetime.min.replace(tzinfo=timezone.utc),
|
||||
)
|
||||
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||
del files[key]
|
||||
changed = True
|
||||
@@ -80,7 +108,10 @@ class TelegramFileCache:
|
||||
if not self._data or "files" not in self._data:
|
||||
return None
|
||||
|
||||
entry = self._data["files"].get(key)
|
||||
# Take a local reference so a concurrent ``async_set`` rebuilding
|
||||
# the dict cannot pull the rug out mid-read.
|
||||
files = self._data["files"]
|
||||
entry = files.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
@@ -88,19 +119,23 @@ class TelegramFileCache:
|
||||
if thumbhash is not None:
|
||||
stored = entry.get("thumbhash")
|
||||
if stored and stored != thumbhash:
|
||||
del self._data["files"][key]
|
||||
# Mark stale — actual deletion happens lock-protected
|
||||
# in the next mutation. Returning None is sufficient
|
||||
# for the caller to skip the cache hit.
|
||||
return None
|
||||
elif self._ttl_seconds > 0:
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
||||
cached_at = _parse_iso(entry.get("cached_at"))
|
||||
if cached_at is not None:
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
if age > self._ttl_seconds:
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": entry.get("file_id"),
|
||||
"type": entry.get("type"),
|
||||
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
|
||||
"size": entry.get("size"),
|
||||
}
|
||||
|
||||
async def async_set(
|
||||
@@ -111,21 +146,22 @@ class TelegramFileCache:
|
||||
thumbhash: str | None = None,
|
||||
size: int | None = None,
|
||||
) -> None:
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
async with self._lock:
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
entry: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
|
||||
self._data["files"][key] = entry
|
||||
await self._backend.save(self._data)
|
||||
self._data["files"][key] = entry
|
||||
await self._backend.save(self._data)
|
||||
|
||||
async def async_set_many(
|
||||
self,
|
||||
@@ -139,32 +175,34 @@ class TelegramFileCache:
|
||||
"""
|
||||
if not entries:
|
||||
return
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
async with self._lock:
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for item in entries:
|
||||
if len(item) == 5:
|
||||
key, file_id, media_type, thumbhash, size = item
|
||||
else:
|
||||
key, file_id, media_type, thumbhash = item
|
||||
size = None
|
||||
entry: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": now_iso,
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
self._data["files"][key] = entry
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for item in entries:
|
||||
if len(item) == 5:
|
||||
key, file_id, media_type, thumbhash, size = item
|
||||
else:
|
||||
key, file_id, media_type, thumbhash = item
|
||||
size = None
|
||||
entry: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": now_iso,
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
self._data["files"][key] = entry
|
||||
|
||||
await self._backend.save(self._data)
|
||||
await self._backend.save(self._data)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
async with self._lock:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return summary stats about the current cache contents.
|
||||
@@ -172,25 +210,33 @@ class TelegramFileCache:
|
||||
Includes the number of cached entries, total tracked size in bytes
|
||||
(only counts entries with a recorded ``size``), and the oldest /
|
||||
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||
Timestamps are compared as parsed ``datetime`` objects so mixed
|
||||
timezone formats (``Z`` vs ``+00:00``) order correctly.
|
||||
"""
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
count = len(files)
|
||||
total_size = 0
|
||||
oldest: str | None = None
|
||||
newest: str | None = None
|
||||
oldest_dt: datetime | None = None
|
||||
newest_dt: datetime | None = None
|
||||
oldest_str: str | None = None
|
||||
newest_str: str | None = None
|
||||
for entry in files.values():
|
||||
size = entry.get("size")
|
||||
if isinstance(size, int):
|
||||
total_size += size
|
||||
cached_at = entry.get("cached_at")
|
||||
if cached_at:
|
||||
if oldest is None or cached_at < oldest:
|
||||
oldest = cached_at
|
||||
if newest is None or cached_at > newest:
|
||||
newest = cached_at
|
||||
dt = _parse_iso(cached_at)
|
||||
if dt is None or not cached_at:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
if oldest_dt is None or dt < oldest_dt:
|
||||
oldest_dt, oldest_str = dt, cached_at
|
||||
if newest_dt is None or dt > newest_dt:
|
||||
newest_dt, newest_str = dt, cached_at
|
||||
return {
|
||||
"count": count,
|
||||
"total_size_bytes": total_size,
|
||||
"oldest": oldest,
|
||||
"newest": newest,
|
||||
"oldest": oldest_str,
|
||||
"newest": newest_str,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Telegram constants
|
||||
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
||||
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
|
||||
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
|
||||
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
|
||||
# Telegram message-text limit (sendMessage) and caption limit
|
||||
# (sendPhoto/sendVideo/sendDocument/first item of sendMediaGroup).
|
||||
TELEGRAM_MAX_TEXT_LENGTH: Final = 4096
|
||||
TELEGRAM_MAX_CAPTION_LENGTH: Final = 1024
|
||||
|
||||
# Generic UUID pattern for asset IDs
|
||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
||||
# Strict canonical-UUID pattern (8-4-4-4-12) for asset IDs. The previous
|
||||
# loose ``[a-f0-9-]{36}`` matched 36 hyphens / arbitrary digit groupings,
|
||||
# which could collide across providers when used as a cache key.
|
||||
_ASSET_ID_PATTERN = re.compile(
|
||||
r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Cache key: "host:uuid" or bare "uuid"
|
||||
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
|
||||
_ASSET_CACHE_KEY_PATTERN = re.compile(
|
||||
r"^(?:[^:]+:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
|
||||
_ASSET_ID_URL_PATTERNS = [
|
||||
@@ -162,5 +177,10 @@ def check_photo_limits(
|
||||
return False, None, width, height
|
||||
except ImportError:
|
||||
return False, None, None, None
|
||||
except Exception:
|
||||
except (OSError, ValueError, MemoryError) as exc:
|
||||
# PIL surfaces ``UnidentifiedImageError`` (subclass of OSError),
|
||||
# truncated-image / decompression-bomb errors here. Log so a
|
||||
# corrupt asset isn't silently passed to Telegram and rejected
|
||||
# downstream with a less actionable error.
|
||||
_LOGGER.warning("check_photo_limits: failed to inspect image (%d bytes): %s", len(data), exc)
|
||||
return False, None, None, None
|
||||
|
||||
@@ -7,36 +7,29 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..ssrf import UnsafeURLError, validate_outbound_url
|
||||
from ..http_base import HttpProviderClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
class WebhookClient(HttpProviderClient):
|
||||
"""Send JSON payloads to a webhook URL.
|
||||
|
||||
class WebhookClient:
|
||||
"""Send JSON payloads to a webhook URL."""
|
||||
The URL is SSRF-validated on every send (defense-in-depth: re-validating
|
||||
catches DNS rebinding between calls and a misconfigured target). Headers
|
||||
pass through :func:`safe_headers` so a target config can't inject
|
||||
framing/hop-by-hop headers like ``Host`` or ``Transfer-Encoding``.
|
||||
"""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, url: str, headers: dict[str, str] | None = None) -> None:
|
||||
self._session = session
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(session, provider_name="webhook")
|
||||
self._url = url
|
||||
self._headers = headers or {}
|
||||
self._extra_headers = headers or {}
|
||||
|
||||
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
validate_outbound_url(self._url)
|
||||
except UnsafeURLError as err:
|
||||
return {"success": False, "error": f"Unsafe URL: {err}"}
|
||||
try:
|
||||
async with self._session.post(
|
||||
self._url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", **self._headers},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
) as response:
|
||||
if 200 <= response.status < 300:
|
||||
return {"success": True, "status_code": response.status}
|
||||
body = await response.text()
|
||||
return {"success": False, "error": f"HTTP {response.status}", "body": body[:200]}
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": str(err)}
|
||||
return await self.request("POST", self._url, json=payload, headers=self._extra_headers)
|
||||
|
||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||
return []
|
||||
|
||||
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""List users known to the Gitea instance via /users/search.
|
||||
|
||||
``/users/search`` with an empty ``q`` returns all users the
|
||||
authenticated token can see, paginated. We cap at ``limit`` to avoid
|
||||
unbounded memory on large instances; the picker only needs enough to
|
||||
cover senders that may appear in webhook payloads.
|
||||
"""
|
||||
users: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
per_page = min(50, limit)
|
||||
while len(users) < limit:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/v1/users/search",
|
||||
headers=self._headers,
|
||||
params={"page": str(page), "limit": str(per_page)},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
|
||||
break
|
||||
body = await response.json()
|
||||
items = body.get("data", []) if isinstance(body, dict) else body
|
||||
if not items:
|
||||
break
|
||||
users.extend(items)
|
||||
if len(items) < per_page:
|
||||
break
|
||||
page += 1
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||
break
|
||||
return users[:limit]
|
||||
|
||||
|
||||
class GiteaApiError(Exception):
|
||||
"""Raised when a Gitea API call fails."""
|
||||
|
||||
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
needs_thumbnail = album_id in album_created_now
|
||||
|
||||
if album_id and album_id != "__dry_run_new__":
|
||||
album = await self._client.get_album(album_id)
|
||||
# Actions diff the current album state to decide what to
|
||||
# add — must observe fresh data, not a cached view.
|
||||
album = await self._client.get_album(album_id, use_cache=False)
|
||||
if album is None and create_if_missing and create_album_name:
|
||||
if not dry_run:
|
||||
created = await self._client.create_album(create_album_name)
|
||||
|
||||
@@ -193,6 +193,27 @@ def get_asset_video_url(
|
||||
return None
|
||||
|
||||
|
||||
def build_asset_media_urls(
|
||||
external_url: str, asset_id: str, asset_type: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(preview_url, full_url)`` for an Immich asset.
|
||||
|
||||
Single source of truth for the photo-vs-video endpoint rule. Used by
|
||||
``asset_to_media`` (notification path) and the bot command handlers
|
||||
(command path) so both always pick the transcoded ``/video/playback``
|
||||
for videos and the preview-sized thumbnail for photos — if they
|
||||
diverge, Telegram ends up delivering a still JPEG for videos in a
|
||||
media group.
|
||||
"""
|
||||
is_video = asset_type == ASSET_TYPE_VIDEO
|
||||
if is_video:
|
||||
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
|
||||
else:
|
||||
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
|
||||
full_url = f"{external_url}/api/assets/{asset_id}/original"
|
||||
return preview_url, full_url
|
||||
|
||||
|
||||
def build_asset_detail(
|
||||
asset: ImmichAssetInfo,
|
||||
external_url: str,
|
||||
@@ -246,12 +267,7 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
# preview_url is what the notification dispatcher feeds to Telegram as the
|
||||
# actual media bytes — for videos it must be the transcoded playback (mp4),
|
||||
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
|
||||
full_url = f"{external_url}/api/assets/{asset.id}/original"
|
||||
else:
|
||||
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
|
||||
full_url = f"{external_url}/api/assets/{asset.id}/original"
|
||||
preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
|
||||
|
||||
return MediaAsset(
|
||||
id=asset.id,
|
||||
@@ -317,8 +333,11 @@ def collect_scheduled_assets(
|
||||
memory_date = now.isoformat() if is_memory else None
|
||||
|
||||
all_eligible: list[ImmichAssetInfo] = []
|
||||
# Track which album each asset belongs to for public URL construction
|
||||
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
|
||||
# Track which album each asset belongs to. Public URL is used to construct
|
||||
# a per-asset share link; name/internal-url are surfaced to templates so
|
||||
# combined-mode sends can attribute each row to its source album.
|
||||
asset_album_map: dict[str, tuple[str, str, str, str]] = {}
|
||||
# asset_id → (album_id, album_public_url, album_name, album_internal_url)
|
||||
collections_extra: list[dict[str, Any]] = []
|
||||
|
||||
# limit=0 is the periodic-summary test path — the caller only needs
|
||||
@@ -330,10 +349,11 @@ def collect_scheduled_assets(
|
||||
for album_id, album in albums.items():
|
||||
links = shared_links.get(album_id, [])
|
||||
album_public_url = get_public_url(external_url, links) or ""
|
||||
album_internal_url = f"{external_url}/albums/{album_id}"
|
||||
|
||||
collections_extra.append({
|
||||
"name": album.name,
|
||||
"url": album_public_url or f"{external_url}/albums/{album_id}",
|
||||
"url": album_public_url or album_internal_url,
|
||||
"public_url": album_public_url,
|
||||
"asset_count": album.asset_count,
|
||||
"shared": album.shared,
|
||||
@@ -354,7 +374,9 @@ def collect_scheduled_assets(
|
||||
)
|
||||
for asset in filtered:
|
||||
if asset.id not in asset_album_map:
|
||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
||||
asset_album_map[asset.id] = (
|
||||
album_id, album_public_url, album.name, album_internal_url,
|
||||
)
|
||||
all_eligible.append(asset)
|
||||
|
||||
if stats_only:
|
||||
@@ -367,15 +389,25 @@ def collect_scheduled_assets(
|
||||
random.shuffle(all_eligible)
|
||||
selected = all_eligible
|
||||
|
||||
# Convert to MediaAsset with public URLs
|
||||
# Convert to MediaAsset with public URLs. Per-asset album_name/album_url
|
||||
# let combined-mode templates attribute each row to its source album —
|
||||
# critical when a tracker spans multiple albums, where the event-level
|
||||
# ``album_name`` (first album only) would be misleading.
|
||||
result: list[MediaAsset] = []
|
||||
for asset in selected:
|
||||
media = asset_to_media(asset, external_url)
|
||||
_, album_pub_url = asset_album_map.get(asset.id, ("", ""))
|
||||
mapped = asset_album_map.get(asset.id)
|
||||
if mapped:
|
||||
_, album_pub_url, album_name, album_internal_url = mapped
|
||||
else:
|
||||
album_pub_url = album_name = album_internal_url = ""
|
||||
if album_pub_url:
|
||||
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
|
||||
else:
|
||||
media.extra.setdefault("public_url", "")
|
||||
media.extra["album_name"] = album_name
|
||||
media.extra["album_url"] = album_pub_url or album_internal_url
|
||||
media.extra["album_public_url"] = album_pub_url
|
||||
result.append(media)
|
||||
|
||||
return result, collections_extra
|
||||
|
||||
@@ -13,6 +13,18 @@ from .models import ImmichAlbumData, ImmichAssetInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Guard against runaway payloads when a bulk import lands in one poll tick.
|
||||
# Templates iterate every entry in ``added_assets`` / ``removed_asset_ids``
|
||||
# in Jinja for-loops (see defaults/*/assets_added.jinja2), and Telegram's
|
||||
# media group has a hard cap of its own — sending 200k entries would both
|
||||
# crash rendering and produce a message that no transport can deliver.
|
||||
#
|
||||
# ``added_count`` / ``removed_count`` on the event always carry the true
|
||||
# totals so templates can show an accurate "N added" number even when the
|
||||
# per-asset list is truncated.
|
||||
_MAX_ASSETS_PER_EVENT = 50
|
||||
_MAX_REMOVALS_PER_EVENT = 200
|
||||
|
||||
|
||||
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
||||
"""Build the common extra dict for album events."""
|
||||
@@ -85,7 +97,17 @@ def detect_album_changes(
|
||||
|
||||
# Emit one event per change type detected
|
||||
if added_assets:
|
||||
media_assets = [asset_to_media(a, external_url) for a in added_assets]
|
||||
total_added = len(added_assets)
|
||||
truncated_added = added_assets[:_MAX_ASSETS_PER_EVENT]
|
||||
media_assets = [asset_to_media(a, external_url) for a in truncated_added]
|
||||
event_extra = dict(extra)
|
||||
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||
event_extra["truncated"] = True
|
||||
event_extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Truncated assets_added event for album %s: %d → %d",
|
||||
new_album.id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
@@ -95,12 +117,22 @@ def detect_album_changes(
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=len(added_assets),
|
||||
added_count=total_added,
|
||||
removed_count=0,
|
||||
extra=dict(extra),
|
||||
extra=event_extra,
|
||||
))
|
||||
|
||||
if removed_ids:
|
||||
total_removed = len(removed_ids)
|
||||
truncated_removed = list(removed_ids)[:_MAX_REMOVALS_PER_EVENT]
|
||||
event_extra = dict(extra)
|
||||
if total_removed > _MAX_REMOVALS_PER_EVENT:
|
||||
event_extra["truncated"] = True
|
||||
event_extra["shown_count"] = _MAX_REMOVALS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Truncated assets_removed event for album %s: %d → %d",
|
||||
new_album.id, total_removed, _MAX_REMOVALS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_REMOVED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
@@ -109,10 +141,10 @@ def detect_album_changes(
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=list(removed_ids),
|
||||
removed_asset_ids=truncated_removed,
|
||||
added_count=0,
|
||||
removed_count=len(removed_ids),
|
||||
extra=dict(extra),
|
||||
removed_count=total_removed,
|
||||
extra=event_extra,
|
||||
))
|
||||
|
||||
if name_changed:
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||
from .models import ImmichAlbumData, SharedLinkInfo
|
||||
from .models import ImmichAlbumData, ImmichAlbumMeta, SharedLinkInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,6 +21,51 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_SEARCH_QUERY_LEN = 256
|
||||
MAX_SEARCH_PERSON_IDS = 50
|
||||
|
||||
# Module-level TTL caches for album bodies and shared-link listings. The
|
||||
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
|
||||
# large album, and bot commands like /random, /latest, /memory all refetch
|
||||
# the same album in quick succession. A short TTL makes repeat runs nearly
|
||||
# instant and deduplicates concurrent fetches so a burst of commands issues
|
||||
# one HTTP call instead of N.
|
||||
#
|
||||
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
|
||||
# constructed fresh per request in several places (api/providers.py,
|
||||
# services/action_runner.py, command handlers), so an instance cache would
|
||||
# never survive to serve a second caller. This mirrors ``_users_cache`` in
|
||||
# ``provider.py``.
|
||||
_ALBUM_CACHE_TTL_SECONDS = 60
|
||||
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
|
||||
# Guard rail against runaway memory — a 200k-asset album response can be
|
||||
# ~150 MB, so even modest caps bound the worst case.
|
||||
_ALBUM_CACHE_MAX_ENTRIES = 32
|
||||
_album_cache_lock = asyncio.Lock()
|
||||
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
|
||||
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
|
||||
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
|
||||
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
||||
_shared_links_cache_lock = asyncio.Lock()
|
||||
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
|
||||
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
|
||||
# every call was already paying for the full server-wide list. Caching the
|
||||
# bucketed result once per server turns N per-album calls into one fetch.
|
||||
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
|
||||
|
||||
|
||||
def _server_digest(url: str, api_key: str) -> str:
|
||||
"""Hashed key that avoids putting raw api_key into cache dict keys."""
|
||||
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def invalidate_album_cache() -> None:
|
||||
"""Drop every cached album body. Call after mutations that invalidate
|
||||
the cached view (e.g. integration tests, manual /refresh commands)."""
|
||||
_album_cache.clear()
|
||||
|
||||
|
||||
def invalidate_shared_links_cache() -> None:
|
||||
"""Drop every cached shared-link listing."""
|
||||
_shared_links_cache.clear()
|
||||
|
||||
# User-facing error bodies — Immich responses may leak internal paths,
|
||||
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||
@@ -184,28 +232,100 @@ class ImmichClient:
|
||||
return {}
|
||||
|
||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||
links: list[SharedLinkInfo] = []
|
||||
bucketed = await self._get_shared_links_bucketed()
|
||||
return list(bucketed.get(album_id, []))
|
||||
|
||||
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
|
||||
the module-level TTL cache first. Underlying Immich endpoint has no
|
||||
per-album filter, so one server-wide fetch serves every caller until
|
||||
the TTL elapses.
|
||||
"""
|
||||
digest = _server_digest(self._url, self._api_key)
|
||||
now = time.monotonic()
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _shared_links_cache_lock:
|
||||
# Re-check under the lock — another coroutine may have refreshed
|
||||
# while we waited.
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
fresh = await self.get_all_shared_links_by_album()
|
||||
_shared_links_cache[digest] = (time.monotonic(), fresh)
|
||||
return fresh
|
||||
|
||||
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Fetch every shared link on the server, bucketed by album id.
|
||||
|
||||
Immich's ``/api/shared-links`` endpoint is server-wide — there's no
|
||||
per-album filter server-side — so every call that wanted the links
|
||||
for a single album was already paying the cost of the full listing
|
||||
and then discarding most of the response. Callers that need links
|
||||
for multiple albums in one tick should use this method and index
|
||||
into the returned dict instead of hitting ``get_shared_links`` in
|
||||
a loop.
|
||||
|
||||
Returns an empty dict on any error (matches the silent-failure
|
||||
contract of ``get_shared_links`` so callers don't need to branch
|
||||
on transient outages).
|
||||
"""
|
||||
result: dict[str, list[SharedLinkInfo]] = {}
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/shared-links",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if album and key and album.get("id") == album_id:
|
||||
links.append(SharedLinkInfo.from_api_response(link))
|
||||
if response.status != 200:
|
||||
_LOGGER.warning(
|
||||
"get_all_shared_links non-200: HTTP %s", response.status
|
||||
)
|
||||
return result
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if not (album and key):
|
||||
continue
|
||||
aid = album.get("id")
|
||||
if not aid:
|
||||
continue
|
||||
result.setdefault(aid, []).append(
|
||||
SharedLinkInfo.from_api_response(link)
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
||||
return links
|
||||
_LOGGER.warning("Failed to fetch all shared links: %s", err)
|
||||
return result
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
users_cache: dict[str, str] | None = None,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> ImmichAlbumData | None:
|
||||
"""Fetch an album by id, optionally serving from the module-level
|
||||
TTL cache. Pass ``use_cache=False`` from paths that must observe the
|
||||
current server state (e.g. the notification poll loop's full-fetch
|
||||
path, where a stale cached entry would delay asset-removal events).
|
||||
Non-cached fetches still populate the cache for subsequent readers.
|
||||
"""
|
||||
cache_key = (_server_digest(self._url, self._api_key), album_id)
|
||||
if use_cache:
|
||||
entry = _album_cache.get(cache_key)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
|
||||
# Rehydrate per-call so ``users_cache`` enrichment is applied
|
||||
# with the caller's dict, not whichever one was live when the
|
||||
# cache was populated.
|
||||
return ImmichAlbumData.from_api_response(entry[1], users_cache)
|
||||
|
||||
# Deliberately fetch without holding a lock so concurrent calls for
|
||||
# *different* album_ids (the common case from asyncio.gather in
|
||||
# fetch_albums_with_links) stay parallel. The worst case is a small
|
||||
# duplicate-fetch stampede when two requests miss the same album at
|
||||
# the same instant — acceptable for our scale.
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
@@ -218,10 +338,132 @@ class ImmichClient:
|
||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async with _album_cache_lock:
|
||||
# Evict the oldest entry if we're at the cap — simple FIFO is fine
|
||||
# for our access pattern (commands touch a small working set).
|
||||
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
|
||||
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
|
||||
_album_cache.pop(oldest, None)
|
||||
_album_cache[cache_key] = (time.monotonic(), data)
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
|
||||
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
|
||||
"""Fetch album metadata without the assets array.
|
||||
|
||||
Uses Immich's ``?withoutAssets=true`` query param, which skips the
|
||||
(potentially huge) ``assets`` field. A 200k-asset album response
|
||||
drops from ~150 MB to a few hundred bytes, so this is cheap enough
|
||||
to run on every poll as a change-detection probe.
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
params={"withoutAssets": "true"},
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
return None
|
||||
if response.status != 200:
|
||||
raise ImmichApiError(
|
||||
f"Error fetching album meta {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return ImmichAlbumMeta.from_api_response(data)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async def search_album_assets_updated_after(
|
||||
self,
|
||||
album_id: str,
|
||||
updated_after: str,
|
||||
*,
|
||||
page_size: int = 1000,
|
||||
max_pages: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch assets in ``album_id`` whose ``updatedAt`` is after ``updated_after``.
|
||||
|
||||
Uses ``POST /api/search/metadata`` with ``albumIds=[album_id]`` and
|
||||
``updatedAfter=<iso>``. Paginates through up to ``max_pages`` pages —
|
||||
the cap exists so a clock-skew or upstream bug cannot produce an
|
||||
infinite loop that exhausts memory on a 200k-asset album. In practice
|
||||
an active album sees a few hundred updated assets per tick and
|
||||
terminates after one page.
|
||||
|
||||
Returns raw Immich asset dicts (same shape as ``album.assets[*]``
|
||||
from ``get_album``), so callers can feed them into
|
||||
``ImmichAssetInfo.from_api_response`` directly.
|
||||
"""
|
||||
if not updated_after:
|
||||
return []
|
||||
|
||||
page_size = max(1, min(page_size, 1000))
|
||||
results: list[dict[str, Any]] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
payload: dict[str, Any] = {
|
||||
"albumIds": [album_id],
|
||||
"updatedAfter": updated_after,
|
||||
"page": page,
|
||||
"size": page_size,
|
||||
# ``withExif`` keeps location/description parity with
|
||||
# ``get_album`` so downstream ``ImmichAssetInfo.from_api_response``
|
||||
# populates city/country/rating on the delta path too.
|
||||
"withExif": True,
|
||||
"withPeople": True,
|
||||
}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body_snip = await response.text()
|
||||
_LOGGER.warning(
|
||||
"Immich delta search non-200: HTTP %s body=%s",
|
||||
response.status, _redact_body(body_snip),
|
||||
)
|
||||
break
|
||||
data = await response.json()
|
||||
assets_block = data.get("assets")
|
||||
if isinstance(assets_block, dict):
|
||||
items = assets_block.get("items", []) or []
|
||||
next_page = assets_block.get("nextPage")
|
||||
elif isinstance(assets_block, list):
|
||||
items = assets_block
|
||||
next_page = None
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich delta search returned unexpected shape: keys=%s",
|
||||
list(data.keys())[:5],
|
||||
)
|
||||
break
|
||||
|
||||
results.extend(items)
|
||||
|
||||
# Stop early on the last page. Immich returns nextPage as
|
||||
# the next page number (string or int) or None/empty when
|
||||
# exhausted. Fall back to page-fullness heuristic if the
|
||||
# server omits the pagination hint.
|
||||
if next_page is None or next_page == "" or next_page == 0:
|
||||
break
|
||||
if len(items) < page_size:
|
||||
break
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Immich delta search transport error: %s", err)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001 — resilience over correctness
|
||||
_LOGGER.warning("Immich delta search parse error: %s", err)
|
||||
break
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich delta search for album %s hit max_pages=%d cap",
|
||||
album_id, max_pages,
|
||||
)
|
||||
return results
|
||||
|
||||
async def get_albums(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self._session.get(
|
||||
|
||||
@@ -146,6 +146,49 @@ class ImmichAssetInfo:
|
||||
return bool(thumbhash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImmichAlbumMeta:
|
||||
"""Lightweight album metadata from ``GET /api/albums/{id}?withoutAssets=true``.
|
||||
|
||||
Used as a cheap change-detection probe so we can skip the multi-MB
|
||||
full-asset fetch when nothing interesting has changed. Large albums
|
||||
(tens to hundreds of thousands of assets) would otherwise re-serialize
|
||||
the entire asset list on every poll interval.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
asset_count: int
|
||||
updated_at: str
|
||||
shared: bool
|
||||
thumbnail_asset_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> ImmichAlbumMeta:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data.get("albumName", "Unnamed"),
|
||||
asset_count=int(data.get("assetCount", 0) or 0),
|
||||
updated_at=data.get("updatedAt", "") or "",
|
||||
shared=bool(data.get("shared", False)),
|
||||
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
||||
)
|
||||
|
||||
def fingerprint(self) -> dict[str, Any]:
|
||||
"""Return a minimal serializable dict for persistence + equality checks.
|
||||
|
||||
We purposefully exclude ``id`` (known from the state row) and keep the
|
||||
dict flat so JSON round-trips are cheap and stable for equality.
|
||||
"""
|
||||
return {
|
||||
"updated_at": self.updated_at,
|
||||
"asset_count": self.asset_count,
|
||||
"shared": self.shared,
|
||||
"name": self.name,
|
||||
"thumbnail_asset_id": self.thumbnail_asset_id or "",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImmichAlbumData:
|
||||
"""Full album data from Immich API."""
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -11,13 +14,62 @@ from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
from .change_detector import detect_album_changes
|
||||
from .asset_utils import asset_to_media
|
||||
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
|
||||
from .client import ImmichClient
|
||||
from .models import ImmichAlbumData
|
||||
from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Module-level users cache shared across ImmichServiceProvider instances.
|
||||
# Users change rarely (new people joining the server, display-name edits), so
|
||||
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
|
||||
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
|
||||
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
|
||||
# raw api_key out of dict keys in case of a memory dump.
|
||||
_USERS_CACHE_TTL_SECONDS = 3600
|
||||
_users_cache_lock = asyncio.Lock()
|
||||
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
|
||||
|
||||
def _users_cache_key(url: str, api_key: str) -> str:
|
||||
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
||||
return digest[:32]
|
||||
|
||||
|
||||
async def _get_cached_users(
|
||||
client: ImmichClient, url: str, api_key: str
|
||||
) -> dict[str, str]:
|
||||
"""Return ``{user_id: display_name}`` for the server, reusing cache entries
|
||||
whose TTL has not elapsed. Misses and stale hits fall through to a real
|
||||
fetch under a single lock so concurrent polls don't stampede the server.
|
||||
"""
|
||||
key = _users_cache_key(url, api_key)
|
||||
now = time.monotonic()
|
||||
entry = _users_cache.get(key)
|
||||
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _users_cache_lock:
|
||||
# Re-check after acquiring the lock — another coroutine may have
|
||||
# refreshed the entry while we waited.
|
||||
entry = _users_cache.get(key)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
fresh = await client.get_users()
|
||||
_users_cache[key] = (time.monotonic(), fresh)
|
||||
return fresh
|
||||
|
||||
|
||||
def invalidate_users_cache() -> None:
|
||||
"""Drop every cached users dict. Exposed for callers that mutate users
|
||||
(e.g. provider config changes, integration tests) and need the next
|
||||
``connect()`` to re-fetch.
|
||||
"""
|
||||
_users_cache.clear()
|
||||
|
||||
|
||||
# Immich-specific template variables
|
||||
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
@@ -135,7 +187,9 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
await self._client.get_server_config()
|
||||
if self._external_domain:
|
||||
self._client.external_domain = self._external_domain
|
||||
self._users_cache = await self._client.get_users()
|
||||
self._users_cache = await _get_cached_users(
|
||||
self._client, self._client.url, self._client.api_key,
|
||||
)
|
||||
return ok
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -150,9 +204,32 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
new_state = dict(tracker_state)
|
||||
external_url = self._client.external_url
|
||||
|
||||
for album_id in collection_ids:
|
||||
album = await self._client.get_album(album_id, self._users_cache)
|
||||
if album is None:
|
||||
# Tick-scoped share-link cache. Populated lazily on first enrichment;
|
||||
# a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
|
||||
# request per tick instead of 5 (and the endpoint is server-wide — each
|
||||
# call was already fetching all links and discarding most of them).
|
||||
self._tick_shared_links: dict[str, list] | None = None
|
||||
|
||||
# Fan out the cheap meta probes in parallel. For a tracker that
|
||||
# watches 20 albums on the same Immich server this turns a 20-hop
|
||||
# serial wait into ~1 round-trip's worth of latency. aiohttp's
|
||||
# connection pool caps concurrency per host, so this can't stampede.
|
||||
meta_results = await asyncio.gather(
|
||||
*(self._client.get_album_meta(aid) for aid in collection_ids),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for album_id, meta_or_exc in zip(collection_ids, meta_results):
|
||||
if isinstance(meta_or_exc, BaseException):
|
||||
# Transient failure on this album — preserve existing state
|
||||
# and move on. Logging at warning so flaky albums surface in
|
||||
# the log without flooding on hard outages.
|
||||
_LOGGER.warning(
|
||||
"Meta probe failed for album %s: %s", album_id, meta_or_exc,
|
||||
)
|
||||
continue
|
||||
meta = meta_or_exc
|
||||
if meta is None:
|
||||
# Album deleted
|
||||
if album_id in new_state:
|
||||
from notify_bridge_core.models.events import EventType
|
||||
@@ -168,11 +245,80 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
del new_state[album_id]
|
||||
continue
|
||||
|
||||
# Get previous state
|
||||
prev = new_state.get(album_id)
|
||||
prev_fingerprint = prev.get("meta_fingerprint") if prev else None
|
||||
has_pending = bool(prev and prev.get("pending_asset_ids"))
|
||||
|
||||
# 2) Fast-path: fingerprint match and no pending assets → no work.
|
||||
# We still refresh the fingerprint slot (no-op if identical) and
|
||||
# leave asset_ids untouched on disk.
|
||||
if (
|
||||
prev is not None
|
||||
and prev_fingerprint == meta.fingerprint()
|
||||
and not has_pending
|
||||
):
|
||||
continue
|
||||
|
||||
# 3) Decide: delta fetch (cheap, active-album case) or full
|
||||
# fetch (first tick + reconciliation for removals).
|
||||
old_fp = prev.get("meta_fingerprint") if prev else None
|
||||
old_asset_count = (old_fp or {}).get("asset_count", 0)
|
||||
old_updated_at = (old_fp or {}).get("updated_at", "")
|
||||
|
||||
# Gate for the delta path:
|
||||
# - must be tracked already (prev exists, has asset_ids)
|
||||
# - must have a prior timestamp (empty ⇒ migrated DB row)
|
||||
# - asset_count must not have decreased (removals need full fetch)
|
||||
can_delta = (
|
||||
prev is not None
|
||||
and bool(prev.get("asset_ids"))
|
||||
and bool(old_updated_at)
|
||||
and meta.asset_count >= old_asset_count
|
||||
)
|
||||
|
||||
if can_delta:
|
||||
delta_events = await self._poll_delta(
|
||||
album_id=album_id,
|
||||
prev=prev,
|
||||
new_meta=meta,
|
||||
old_updated_at=old_updated_at,
|
||||
)
|
||||
if delta_events is not None:
|
||||
events.extend(delta_events["events"])
|
||||
new_state[album_id] = delta_events["new_state"]
|
||||
continue
|
||||
# delta_events is None ⇒ delta saw more additions than the
|
||||
# net count increase (mixed add+remove) ⇒ fall through to
|
||||
# the full-fetch path so removals get detected.
|
||||
|
||||
# Full fetch: first tick, or count-decreased, or delta-unsafe.
|
||||
# Bypass the module-level album cache — this path runs when we
|
||||
# specifically need the current server state (e.g. to detect
|
||||
# asset removals), so a stale cached entry would silently delay
|
||||
# the event.
|
||||
album = await self._client.get_album(
|
||||
album_id, self._users_cache, use_cache=False,
|
||||
)
|
||||
if album is None:
|
||||
# Album was deleted between meta probe and full fetch — handle
|
||||
# the deletion the same way as above.
|
||||
if album_id in new_state:
|
||||
from notify_bridge_core.models.events import EventType
|
||||
from datetime import datetime, timezone
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_DELETED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
del new_state[album_id]
|
||||
continue
|
||||
|
||||
if prev is None:
|
||||
# First time seeing this album — store state, no event
|
||||
new_state[album_id] = _serialize_album_state(album)
|
||||
new_state[album_id] = _serialize_album_state(album, meta)
|
||||
continue
|
||||
|
||||
# Reconstruct previous album data for comparison
|
||||
@@ -184,34 +330,233 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
)
|
||||
|
||||
if detected_events:
|
||||
# Fetch shared links to enrich events with public_url
|
||||
shared_links = await self._client.get_shared_links(album_id)
|
||||
public_link = None
|
||||
protected_link = None
|
||||
for link in shared_links:
|
||||
if link.is_accessible and not link.is_expired:
|
||||
if link.has_password:
|
||||
protected_link = link
|
||||
else:
|
||||
public_link = link
|
||||
break # prefer non-password link
|
||||
|
||||
ext_domain = self._external_domain or self._client.external_url
|
||||
for evt in detected_events:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
await self._enrich_with_shared_links(album_id, detected_events)
|
||||
events.extend(detected_events)
|
||||
|
||||
# Update state
|
||||
state = _serialize_album_state(album)
|
||||
state = _serialize_album_state(album, meta)
|
||||
state["pending_asset_ids"] = list(updated_pending)
|
||||
new_state[album_id] = state
|
||||
|
||||
return events, new_state
|
||||
|
||||
async def _poll_delta(
|
||||
self,
|
||||
*,
|
||||
album_id: str,
|
||||
prev: dict[str, Any],
|
||||
new_meta: ImmichAlbumMeta,
|
||||
old_updated_at: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Delta-fetch path for an active album.
|
||||
|
||||
Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
|
||||
the full asset list. Returns a dict with ``events`` and ``new_state``
|
||||
on success, or ``None`` to signal the caller to retry via full fetch
|
||||
(used when a mixed add+remove is detected — the delta endpoint can't
|
||||
tell us *what* was removed, only that additions alone don't account
|
||||
for the net count change).
|
||||
|
||||
Trades strict detection of removals-during-mixed-changes for a
|
||||
drastic reduction in bytes fetched per tick. On a 200k-asset album
|
||||
where 50 were just added, we fetch ~50 asset records instead of
|
||||
200 000.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from notify_bridge_core.models.events import EventType
|
||||
|
||||
prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
|
||||
prev_pending: set[str] = set(prev.get("pending_asset_ids", []))
|
||||
|
||||
raw_assets = await self._client.search_album_assets_updated_after(
|
||||
album_id, old_updated_at
|
||||
)
|
||||
|
||||
# Parse everything that came back. We need unprocessed entries too
|
||||
# (they feed the ``pending_asset_ids`` list used by the original
|
||||
# change detector's processed-later logic).
|
||||
delta_assets: list[ImmichAssetInfo] = []
|
||||
for raw in raw_assets:
|
||||
try:
|
||||
delta_assets.append(
|
||||
ImmichAssetInfo.from_api_response(raw, self._users_cache)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 — one bad record ≠ abort tick
|
||||
_LOGGER.warning(
|
||||
"Skipping malformed asset record in delta response: %s", err
|
||||
)
|
||||
|
||||
newly_added: list[ImmichAssetInfo] = []
|
||||
still_pending: set[str] = set()
|
||||
for asset in delta_assets:
|
||||
if asset.is_processed:
|
||||
if asset.id not in prev_asset_ids:
|
||||
newly_added.append(asset)
|
||||
else:
|
||||
still_pending.add(asset.id)
|
||||
|
||||
old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
|
||||
net_change = new_meta.asset_count - old_asset_count
|
||||
|
||||
# If delta found more "added" assets than the net count change,
|
||||
# a concurrent removal happened. Full fetch is the only way to
|
||||
# know what was removed — bail out so the caller retries.
|
||||
if net_change >= 0 and len(newly_added) > net_change:
|
||||
_LOGGER.info(
|
||||
"Delta for album %s found %d additions but net change is %d "
|
||||
"— falling back to full fetch for removal reconciliation",
|
||||
album_id, len(newly_added), net_change,
|
||||
)
|
||||
return None
|
||||
|
||||
# Mirror case: positive net change we couldn't account for with the
|
||||
# delta results (possibly clock skew on ``updated_at``, or an asset
|
||||
# whose timestamp is before ``old_updated_at`` yet the album's
|
||||
# ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
|
||||
if net_change > 0 and len(newly_added) < net_change:
|
||||
_LOGGER.info(
|
||||
"Delta for album %s found %d additions but net change is %d "
|
||||
"— falling back to full fetch to avoid missing assets",
|
||||
album_id, len(newly_added), net_change,
|
||||
)
|
||||
return None
|
||||
|
||||
events: list[ServiceEvent] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
external_url = self._external_domain or self._client.external_url
|
||||
album_url = f"{external_url}/albums/{album_id}"
|
||||
|
||||
# Carry album-level attributes we know from the cheap meta probe.
|
||||
# Shared-link enrichment happens further down only if we emitted
|
||||
# any asset events.
|
||||
base_extra = {
|
||||
"album_url": album_url,
|
||||
"shared": new_meta.shared,
|
||||
"asset_count": new_meta.asset_count,
|
||||
"photo_count": 0, # unknown without per-asset scan; templates tolerate 0
|
||||
"video_count": 0,
|
||||
"people": [],
|
||||
"owner": "",
|
||||
}
|
||||
|
||||
# Metadata-only events (no asset fetch needed)
|
||||
old_fp = prev.get("meta_fingerprint") or {}
|
||||
if old_fp.get("name") and old_fp["name"] != new_meta.name:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_RENAMED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_name=old_fp["name"],
|
||||
new_name=new_meta.name,
|
||||
extra=dict(base_extra),
|
||||
))
|
||||
|
||||
if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.SHARING_CHANGED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_shared=bool(old_fp["shared"]),
|
||||
new_shared=bool(new_meta.shared),
|
||||
extra=dict(base_extra),
|
||||
))
|
||||
|
||||
if newly_added:
|
||||
total_added = len(newly_added)
|
||||
truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
|
||||
media_assets = [
|
||||
asset_to_media(a, self._client.external_url) for a in truncated
|
||||
]
|
||||
extra = dict(base_extra)
|
||||
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||
extra["truncated"] = True
|
||||
extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Delta-path truncated assets_added event for album %s: %d → %d",
|
||||
album_id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=total_added,
|
||||
removed_count=0,
|
||||
extra=extra,
|
||||
))
|
||||
|
||||
if events:
|
||||
await self._enrich_with_shared_links(album_id, events)
|
||||
|
||||
# Rebuild state. asset_ids grows by the newly-added processed set.
|
||||
# pending is the union of the prior pending list (things still in
|
||||
# flight) and anything the delta confirmed as not-yet-processed.
|
||||
# When net_change is 0 or negative we trust the meta count over
|
||||
# our bookkeeping — skip-path will fix drift on the next full fetch.
|
||||
new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
|
||||
# Discard any previously-pending IDs that just landed as processed.
|
||||
new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}
|
||||
|
||||
return {
|
||||
"events": events,
|
||||
"new_state": {
|
||||
"name": new_meta.name,
|
||||
"asset_ids": list(new_asset_ids),
|
||||
"shared": new_meta.shared,
|
||||
"pending_asset_ids": list(new_pending),
|
||||
"meta_fingerprint": new_meta.fingerprint(),
|
||||
},
|
||||
}
|
||||
|
||||
async def _enrich_with_shared_links(
|
||||
self, album_id: str, events_to_enrich: list[ServiceEvent]
|
||||
) -> None:
|
||||
"""Attach public/protected share link URLs to events for this album.
|
||||
|
||||
Uses the tick-scoped bulk cache populated lazily on first call, so a
|
||||
tracker with changes across N albums makes one ``/api/shared-links``
|
||||
request per tick instead of N.
|
||||
"""
|
||||
if self._tick_shared_links is None:
|
||||
self._tick_shared_links = await self._client.get_all_shared_links_by_album()
|
||||
|
||||
shared_links = self._tick_shared_links.get(album_id, [])
|
||||
public_link = None
|
||||
protected_link = None
|
||||
for link in shared_links:
|
||||
if link.is_accessible and not link.is_expired:
|
||||
if link.has_password:
|
||||
protected_link = link
|
||||
else:
|
||||
public_link = link
|
||||
break # prefer non-password link
|
||||
|
||||
ext_domain = self._external_domain or self._client.external_url
|
||||
for evt in events_to_enrich:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(IMMICH_VARIABLES)
|
||||
|
||||
@@ -262,13 +607,33 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
return {"ok": False, "message": "Failed to connect to Immich"}
|
||||
|
||||
|
||||
def _serialize_album_state(album: ImmichAlbumData) -> dict[str, Any]:
|
||||
"""Serialize album state for persistence."""
|
||||
def _serialize_album_state(
|
||||
album: ImmichAlbumData,
|
||||
meta: ImmichAlbumMeta | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Serialize album state for persistence.
|
||||
|
||||
``meta`` carries the fingerprint used for cheap no-change detection on
|
||||
subsequent polls. When omitted (legacy callers, tests) we synthesize a
|
||||
best-effort fingerprint from the full album — it will still match on the
|
||||
next tick if nothing changed, which is what matters.
|
||||
"""
|
||||
if meta is None:
|
||||
fingerprint = {
|
||||
"updated_at": album.updated_at,
|
||||
"asset_count": len(album.asset_ids),
|
||||
"shared": album.shared,
|
||||
"name": album.name,
|
||||
"thumbnail_asset_id": album.thumbnail_asset_id or "",
|
||||
}
|
||||
else:
|
||||
fingerprint = meta.fingerprint()
|
||||
return {
|
||||
"name": album.name,
|
||||
"asset_ids": list(album.asset_ids),
|
||||
"shared": album.shared,
|
||||
"pending_asset_ids": [],
|
||||
"meta_fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user