Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd7329177 | |||
| ba199f24bd | |||
| bb5afcc222 | |||
| 4335036c22 | |||
| 5d41a39406 | |||
| 6229bf9b74 | |||
| a666bad0c4 | |||
| bede928a3f | |||
| 87cb33cffe | |||
| 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 | |||
| 325eabd751 | |||
| fab6169cf9 | |||
| 85311684d9 | |||
| d7daadadc2 | |||
| e04ad16ca6 | |||
| d7d0a5d921 | |||
| 93df538819 | |||
| 2be608ba95 | |||
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 | |||
| 83215473c7 | |||
| 4e23d2b054 | |||
| f7d51b27d2 | |||
| 3bb0585e43 | |||
| 58cba88c92 | |||
| 645331d320 | |||
| 6c3dd67c1b | |||
| 56993d2ca3 | |||
| fe92b206b7 | |||
| cf4976da2f | |||
| 80c034d2af | |||
| a7a2b4efa4 |
@@ -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/
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
A generic bridge between service providers and notification targets.
|
||||
|
||||
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
|
||||
notifications to configurable targets (Telegram, webhooks) using customizable templates.
|
||||
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
|
||||
and internal scheduler) for changes and dispatches notifications to configurable targets
|
||||
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Service Providers** — Connectors to external services (Immich, more coming)
|
||||
- **Service Providers** — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
|
||||
- **Trackers** — Monitor specific collections within a provider for changes
|
||||
- **Tracking Configs** — Define what events to watch for and scheduling rules
|
||||
- **Notification Targets** — Where to send notifications (Telegram chats, webhook URLs)
|
||||
- **Notification Targets** — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
|
||||
- **Template Configs** — Jinja2 templates that format notifications per provider type
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
packages/
|
||||
core/ — Shared library: providers, models, notifications, templates
|
||||
server/ — FastAPI REST server with SQLite database
|
||||
@@ -31,6 +32,7 @@ docker run -d \
|
||||
-p 8420:8420 \
|
||||
-v notify-bridge-data:/data \
|
||||
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
|
||||
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
|
||||
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
|
||||
```
|
||||
|
||||
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| -------- | -------- | ------- | ----------- |
|
||||
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
|
||||
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
|
||||
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
|
||||
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
|
||||
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
|
||||
| `CORS_ALLOWED_ORIGINS` | Recommended | `http://localhost:5175` | Comma-separated browser origins. Wildcard `*` is **rejected** because credentials are enabled. Set this to the URL you load the UI from. |
|
||||
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
|
||||
| `DATABASE_URL` | No | `sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db` | Override DB connection string. |
|
||||
| `HOST` | No | `0.0.0.0` | Bind address. |
|
||||
| `PORT` | No | `8420` | Server listen port. |
|
||||
| `DEBUG` | No | `false` | Enable debug logging. |
|
||||
|
||||
Reverse proxy / network:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse proxy IP (e.g. `172.17.0.1` for the default Docker bridge). Use `*` only when the container is not directly internet-reachable. |
|
||||
| `EXTERNAL_URL` | — | Public base URL (e.g. `https://notify.example.com`). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
|
||||
| `ALLOW_PRIVATE_URLS` | unset | Set to `1` to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). **Do not enable on a publicly exposed instance.** |
|
||||
|
||||
Auth & tokens:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Lifetime of access JWTs. |
|
||||
| `REFRESH_TOKEN_EXPIRE_DAYS` | `30` | Lifetime of refresh tokens. |
|
||||
| `JWT_ISSUER` | `notify-bridge` | `iss` claim. |
|
||||
| `JWT_AUDIENCE` | `notify-bridge-api` | `aud` claim. |
|
||||
|
||||
Logging (all are also live-editable in the Settings page, except `log_format`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `LOG_LEVEL` | `INFO` | Root level: `DEBUG` / `INFO` / `WARNING` / `ERROR`. |
|
||||
| `LOG_FORMAT` | `text` | `text` or `json`. Switching requires a restart. |
|
||||
| `LOG_LEVELS` | — | Per-module overrides, e.g. `notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO`. |
|
||||
|
||||
Retention & maintenance:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history to keep. `0` disables the retention job. |
|
||||
| `PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots to keep in `<DATA_DIR>/backups/`. `0` disables snapshotting. |
|
||||
| `GRACEFUL_SHUTDOWN_SECONDS` | `60` | Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
|
||||
|
||||
Integrations & misc:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | — | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
|
||||
| `TIMEZONE` | `UTC` | IANA timezone (e.g. `Europe/Warsaw`) used by the scheduler. Also settable from the Settings page. |
|
||||
| `STATIC_DIR` | `/app/static` (in Docker) | Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
|
||||
| `SUPERVISED` | auto-detect | Set to `1` to tell the backup endpoint that an external supervisor will restart the process. |
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -58,12 +107,50 @@ services:
|
||||
volumes:
|
||||
- notify-bridge-data:/data
|
||||
environment:
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
|
||||
# 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)}
|
||||
# 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:
|
||||
# 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: 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:
|
||||
```
|
||||
|
||||
A ready-to-use `docker-compose.yml` lives at the repo root.
|
||||
|
||||
### Health & Readiness
|
||||
|
||||
- `GET /api/health` — process is up. Use for liveness probes.
|
||||
- `GET /api/ready` — migrations + scheduler have booted. Use for readiness probes and Docker `HEALTHCHECK` (as the compose example above does).
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
@@ -81,4 +168,48 @@ npm run dev
|
||||
|
||||
## Supported Providers
|
||||
|
||||
- **Immich** — Photo/video server with album change detection
|
||||
- **Immich** — Photo/video server with album change detection (polling)
|
||||
- **Gitea** — Git server with push / issue / PR / release events (webhook)
|
||||
- **Planka** — Kanban board with card / list / board events (webhook)
|
||||
- **NUT** — Network UPS Tools for battery / power events (polling)
|
||||
- **Google Photos** — Album change detection (polling)
|
||||
- **Generic Webhook** — Catch arbitrary JSON payloads and route them via templates (webhook)
|
||||
- **Scheduler** — Internal provider for time-based scheduled messages
|
||||
|
||||
## Supported Notification Targets
|
||||
|
||||
- **Telegram** — Bot API with rich formatting, media groups, and inline commands
|
||||
- **Discord** — Webhook-based delivery with embeds
|
||||
- **Slack** — Incoming webhooks with Block Kit formatting
|
||||
- **Matrix** — Homeserver delivery with HTML formatting
|
||||
- **ntfy** — Self-hostable push notifications
|
||||
- **Email** — SMTP with HTML / plain-text templates
|
||||
- **Generic Webhook** — POST custom JSON payloads to any URL
|
||||
|
||||
## Bot Commands
|
||||
|
||||
Telegram bots can serve interactive commands per provider. All commands use
|
||||
Jinja2 templates that you can customize from the **Command Templates** page.
|
||||
|
||||
| Provider | Commands |
|
||||
| -------- | -------- |
|
||||
| Immich | `/status` `/albums` `/events` `/summary` `/latest` `/memory` `/random` `/search` `/find` `/person` `/place` `/favorites` `/people` `/help` |
|
||||
| Gitea | `/status` `/repos` `/issues` `/prs` `/commits` `/help` |
|
||||
| Planka | `/status` `/boards` `/cards` `/lists` `/help` |
|
||||
| NUT | `/status` `/devices` `/battery` `/help` |
|
||||
| Google Photos | `/status` `/albums` `/latest` `/search` `/random` `/help` |
|
||||
| Generic Webhook | `/status` `/help` |
|
||||
|
||||
Every provider also responds to `/start`, and rate-limit / empty-result
|
||||
fallback messages are templated as well.
|
||||
|
||||
## Smart Actions
|
||||
|
||||
Beyond notifications, providers can run **actions** against the source service.
|
||||
Currently implemented:
|
||||
|
||||
- **Immich — Auto-Organize** — Automatically sort newly-detected assets into
|
||||
albums based on configurable rules. Each rule combines criteria (people in
|
||||
the photo, search query, favorites, date range) with a target album, and can
|
||||
create the album if it doesn't exist. Supports dry-run mode for previewing
|
||||
what would move before committing.
|
||||
|
||||
+20
-224
@@ -1,146 +1,31 @@
|
||||
## v0.1.0 (2026-04-21)
|
||||
# v0.8.0 (2026-05-12)
|
||||
|
||||
First public release of **Notify Bridge** — a self-hosted bridge that turns events from home-lab services into rich, localized notifications and accepts chat commands in return.
|
||||
|
||||
### Highlights
|
||||
|
||||
- Six service providers out of the box: Immich, Google Photos, Planka, Gitea, NUT (Network UPS Tools), plus a generic JSONPath webhook provider and a built-in Scheduler.
|
||||
- Multi-channel delivery: Telegram, Discord, Slack, ntfy, Matrix, Email, and a broadcast target that fans out to multiple receivers.
|
||||
- Provider-agnostic bot command system with rich, locale-aware command templates (Telegram + Matrix + Email bots).
|
||||
- Jinja2 slot-based template system with autocomplete, live preview, locale switching, and a sandbox with timeout protection.
|
||||
- Actions engine for scheduled mutations on external services (e.g. timed Immich operations).
|
||||
- Dashboard with filtered charts, grouped navigation tree with badges, Ctrl+K search palette, cross-entity crosslinks, card-highlight navigation, and a global provider filter.
|
||||
- Docker deployment with a Gitea CI/CD pipeline, full backup & restore, webhook payload history, person excludes for auto-organize rules, and SSRF-hardened outgoing requests.
|
||||
|
||||
---
|
||||
## User-facing changes
|
||||
|
||||
### Features
|
||||
|
||||
#### Service providers
|
||||
- Phase 3 — Immich service provider ([cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558))
|
||||
- Google Photos provider backend + API hardening ([307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c))
|
||||
- Planka service provider with full notification and command support ([0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6))
|
||||
- Gitea as webhook-based service provider ([6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb))
|
||||
- NUT (Network UPS Tools) service provider + provider-agnostic UI ([68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b))
|
||||
- Generic webhook provider with JSONPath payload extraction ([616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221))
|
||||
- Scheduler provider + multi-provider UX fixes ([0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78))
|
||||
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
|
||||
#### Notification targets & delivery
|
||||
- Discord / Slack / ntfy / Matrix targets, command templates, delete protection, email/matrix bots ([3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0))
|
||||
- Broadcast notification target + UX improvements ([d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60))
|
||||
- Provider-strict configs, slot-based templates, broadcast targets, email bots, command templates ([846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480))
|
||||
- Receiver OOP hierarchy with per-receiver locale resolution ([1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728))
|
||||
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
|
||||
### Documentation
|
||||
|
||||
#### Bots & commands
|
||||
- Telegram commands, app settings, bot polling, webhook handling, UI improvements ([03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3))
|
||||
- Per-chat command toggle, listener name + toggle in bot tab ([b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31))
|
||||
- Rich command templates with public links + media text-first flow ([d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767))
|
||||
- Locale-aware command templates, debounced auto-sync, entity pickers ([1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13))
|
||||
- Remove hardcoded command templates, enforce template system exclusively ([ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda))
|
||||
|
||||
#### Template system
|
||||
- Phase 4 — template system ([f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070))
|
||||
- Locale-aware notification templates + UX improvements ([37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4))
|
||||
- Collapsible accordion slots for template editing UX ([b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8))
|
||||
- Smart video size warnings + Jinja2 template autocomplete ([39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82))
|
||||
- Collapsible chart, paginator controls, localized template slots ([3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761))
|
||||
- Fix template preview links, default chat action, update default templates ([371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70))
|
||||
|
||||
#### Entities, targets & rules
|
||||
- Port full CRUD API routes and frontend pages from Immich Watcher ([9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a))
|
||||
- Entity relationship refactor — notification trackers, command system, chat actions ([1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3))
|
||||
- Person excludes for auto-organize rules, backup & restore system ([6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113))
|
||||
- Actions system — scheduled mutations on external services ([6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf))
|
||||
- Default tracker configs, email validation, expandable target links ([6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926))
|
||||
- Webhook payload history — store and display recent incoming payloads ([6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00))
|
||||
- Test menu dropdown, split text/media messages, target settings, provider URL links ([5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37))
|
||||
|
||||
#### UI / navigation / UX
|
||||
- Phase 7 — frontend restructuring ([9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7))
|
||||
- Port original frontend UI to Notify Bridge ([c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93))
|
||||
- Grouped nav tree with badges, dashboard events section with filtered chart ([2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff))
|
||||
- Entity cache system, nav UX improvements, split CLAUDE.md ([563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f))
|
||||
- IconGridSelect, CrossLink, SearchPalette components + entity crosslinks ([06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463))
|
||||
- Card highlight system for cross-entity navigation ([f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db))
|
||||
- Search button in sidebar with Ctrl+K shortcut hint ([637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467))
|
||||
- EntitySelect palette-style entity picker, replace select dropdowns ([a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3))
|
||||
- Provider type selector for tracking-configs, use IconGridSelect everywhere ([9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9))
|
||||
- Replace all select dropdowns with IconGridSelect, fix EN template seed ([a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912))
|
||||
- Filter search to IconGridSelect when item count > 4 ([a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4))
|
||||
- Consistent IconGridSelect sizing + descriptions + filter upgrades ([31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5))
|
||||
- Filtering on all entity list pages ([7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d))
|
||||
- Filter entity selectors by global provider filter ([c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d))
|
||||
- Chat language display, disabled EntitySelect items, dev scripts ([82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d))
|
||||
- API docs link button in sidebar footer ([f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36))
|
||||
- UX & notification improvements — icons, events, chat names, link validation, templates ([03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66))
|
||||
- UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish ([734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9))
|
||||
|
||||
#### Security & hardening
|
||||
- Security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish ([f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca))
|
||||
- Comprehensive code review fixes — security, performance, quality ([e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39))
|
||||
- Comprehensive code review fixes + receivers-only architecture ([751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b))
|
||||
|
||||
#### Deployment
|
||||
- Docker deployment + Gitea CI/CD workflow ([1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17))
|
||||
|
||||
#### Foundation
|
||||
- Phase 9 — HAOS integration planning ([786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e))
|
||||
- Phase 8 — integration and wiring ([08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9))
|
||||
- Phase 6 — database models and server API ([7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89))
|
||||
- Phase 5 — notification system ([16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef))
|
||||
- Phase 2 — core abstractions ([3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c))
|
||||
- Phase 1 — project scaffolding ([b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Simplify add-target UX — single EntitySelect click to add ([21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7))
|
||||
- Provider-aware collection count labels in tracker list ([c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5))
|
||||
- NUT template preview + tracking config event checkboxes ([2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6))
|
||||
- Dashboard provider card shows filtered count, fix provider update 400 ([0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7))
|
||||
- UI polish — overflow, placeholders, dashboard provider card ([4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe))
|
||||
- Pass chat_action from target config to Telegram client ([e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128))
|
||||
- Remove all transform from stagger/fade animations ([d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0))
|
||||
- Stagger animation breaking position:fixed overlays ([f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf))
|
||||
- Remove Card hover transform that breaks fixed-position overlays ([bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de))
|
||||
- Clipboard copy fallback for non-HTTPS contexts ([c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d))
|
||||
- Nav active state — plain path link not highlighted when sibling query-param link matches ([f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5))
|
||||
- Re-create missing EN default template, provider type as IconGridSelect ([db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5))
|
||||
- Search palette triggers highlight, restore CSS keyframe blink ([86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5))
|
||||
- Switch highlight to global store instead of URL params ([88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4))
|
||||
- Replace CSS keyframe highlight with direct style pulse for reliability ([f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93))
|
||||
- Card highlight animation — kill stagger before highlight, keep animation:none on cleanup ([4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40))
|
||||
- Prevent stagger animation replay after card highlight ends ([4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8))
|
||||
- Rename bots → telegramBots in targets page to fix undefined reference ([227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2))
|
||||
- Comprehensive API/UI review — 26 bug fixes and improvements ([91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5))
|
||||
- Remove auto-redirect from API client on 401 ([e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed))
|
||||
- Add auth guard to root layout with setup/login redirects ([7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6))
|
||||
- Local fonts via @fontsource, favicon, autocomplete attrs ([f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa))
|
||||
|
||||
### Performance
|
||||
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
|
||||
- Lazy-load @mdi/js to reduce Vite dev server memory usage ([826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c))
|
||||
|
||||
### Refactoring
|
||||
- Comprehensive consistency review — UI/UX, code quality, functional parity ([6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164))
|
||||
- Comprehensive codebase review — security, performance, quality, UX ([b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00))
|
||||
- Provider descriptor registry — eliminate provider-specific hardcoding ([8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e))
|
||||
- Provider-agnostic bot command system + Gitea commands ([63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1))
|
||||
- Unify test dispatch with real NotificationDispatcher ([d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388))
|
||||
- Replace favorites checkbox with toggle switch in grid layout ([1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e))
|
||||
- Rename /telegram-bots route to /bots ([b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e))
|
||||
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
## Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step ([eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2))
|
||||
- Sync release workflow with CI/CD docs, add manual build ([c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f))
|
||||
### Architecture
|
||||
|
||||
#### Chores
|
||||
- Pre-release cleanup ([90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc))
|
||||
- Remove accidentally committed __pycache__ ([0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f))
|
||||
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
|
||||
### Tests
|
||||
|
||||
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
|
||||
---
|
||||
|
||||
@@ -149,97 +34,8 @@ First public release of **Notify Bridge** — a self-hosted bridge that turns ev
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc) | chore: pre-release cleanup | alexei.dolgolyov |
|
||||
| [eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2) | ci: consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step | alexei.dolgolyov |
|
||||
| [f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca) | feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish | alexei.dolgolyov |
|
||||
| [734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9) | feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish | alexei.dolgolyov |
|
||||
| [6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113) | feat: person excludes for auto-organize rules, backup & restore system | alexei.dolgolyov |
|
||||
| [6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164) | refactor: comprehensive consistency review — UI/UX, code quality, functional parity | alexei.dolgolyov |
|
||||
| [6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00) | feat: webhook payload history — store and display recent incoming payloads | alexei.dolgolyov |
|
||||
| [c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f) | ci: sync release workflow with CI/CD docs, add manual build | alexei.dolgolyov |
|
||||
| [b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00) | refactor: comprehensive codebase review — security, performance, quality, UX | alexei.dolgolyov |
|
||||
| [616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221) | feat: generic webhook provider with JSONPath payload extraction | alexei.dolgolyov |
|
||||
| [307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c) | feat: Google Photos provider backend + API hardening | alexei.dolgolyov |
|
||||
| [3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761) | feat: collapsible chart, paginator controls, localized template slots | alexei.dolgolyov |
|
||||
| [21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7) | fix: simplify add-target UX — single EntitySelect click to add | alexei.dolgolyov |
|
||||
| [6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926) | feat: default tracker configs, email validation, expandable target links | alexei.dolgolyov |
|
||||
| [d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388) | refactor: unify test dispatch with real NotificationDispatcher | alexei.dolgolyov |
|
||||
| [1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e) | refactor: replace favorites checkbox with toggle switch in grid layout | alexei.dolgolyov |
|
||||
| [b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8) | feat: collapsible accordion slots for template editing UX | alexei.dolgolyov |
|
||||
| [d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767) | feat: rich command templates with public links + media text-first flow | alexei.dolgolyov |
|
||||
| [f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36) | feat: add API docs link button in sidebar footer | alexei.dolgolyov |
|
||||
| [ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33) | perf: rewrite asset URLs to internal provider URL for LAN fetching | alexei.dolgolyov |
|
||||
| [d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60) | feat: broadcast notification target + UX improvements | alexei.dolgolyov |
|
||||
| [8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e) | refactor: provider descriptor registry — eliminate provider-specific hardcoding | alexei.dolgolyov |
|
||||
| [c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5) | fix: provider-aware collection count labels in tracker list | alexei.dolgolyov |
|
||||
| [2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6) | fix: NUT template preview + tracking config event checkboxes | alexei.dolgolyov |
|
||||
| [68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b) | feat: NUT (Network UPS Tools) service provider + provider-agnostic UI | alexei.dolgolyov |
|
||||
| [c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d) | feat: filter entity selectors by global provider filter | alexei.dolgolyov |
|
||||
| [0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7) | fix: dashboard provider card shows filtered count, fix provider update 400 | alexei.dolgolyov |
|
||||
| [4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe) | fix: UI polish — overflow, placeholders, dashboard provider card | alexei.dolgolyov |
|
||||
| [1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728) | feat: Receiver OOP hierarchy with per-receiver locale resolution | alexei.dolgolyov |
|
||||
| [b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31) | feat: per-chat command toggle, listener name + toggle in bot tab | alexei.dolgolyov |
|
||||
| [37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4) | feat: locale-aware notification templates + UX improvements | alexei.dolgolyov |
|
||||
| [6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf) | feat: Actions system — scheduled mutations on external services | alexei.dolgolyov |
|
||||
| [0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6) | feat: add Planka service provider with full notification and command support | alexei.dolgolyov |
|
||||
| [39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82) | feat: smart video size warnings + Jinja2 template autocomplete | alexei.dolgolyov |
|
||||
| [1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17) | feat: Docker deployment + Gitea CI/CD workflow | alexei.dolgolyov |
|
||||
| [e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39) | feat: comprehensive code review fixes — security, performance, quality | alexei.dolgolyov |
|
||||
| [31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5) | feat: consistent IconGridSelect sizing + descriptions + filter upgrades | alexei.dolgolyov |
|
||||
| [82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d) | feat: chat language display, disabled EntitySelect items, dev scripts | alexei.dolgolyov |
|
||||
| [e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128) | fix: pass chat_action from target config to Telegram client | alexei.dolgolyov |
|
||||
| [d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0) | fix: remove all transform from stagger/fade animations | alexei.dolgolyov |
|
||||
| [f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf) | fix: stagger animation breaking position:fixed overlays | alexei.dolgolyov |
|
||||
| [bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de) | fix: remove Card hover transform that breaks fixed-position overlays | alexei.dolgolyov |
|
||||
| [c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d) | fix: clipboard copy fallback for non-HTTPS contexts | alexei.dolgolyov |
|
||||
| [7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d) | feat: add filtering to all entity list pages | alexei.dolgolyov |
|
||||
| [63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1) | refactor: provider-agnostic bot command system + Gitea commands | alexei.dolgolyov |
|
||||
| [0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78) | feat: add Scheduler provider + multi-provider UX fixes | alexei.dolgolyov |
|
||||
| [6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb) | feat: add Gitea as webhook-based service provider | alexei.dolgolyov |
|
||||
| [1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13) | feat: locale-aware command templates, debounced auto-sync, entity pickers | alexei.dolgolyov |
|
||||
| [751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b) | feat: comprehensive code review fixes + receivers-only architecture | alexei.dolgolyov |
|
||||
| [b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e) | refactor: rename /telegram-bots route to /bots | alexei.dolgolyov |
|
||||
| [f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5) | fix: nav active state — plain path link not highlighted when sibling query-param link matches | alexei.dolgolyov |
|
||||
| [826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c) | perf: lazy-load @mdi/js to reduce Vite dev server memory usage | alexei.dolgolyov |
|
||||
| [a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4) | feat: add filter search to IconGridSelect when item count > 4 | alexei.dolgolyov |
|
||||
| [a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912) | feat: replace all select dropdowns with IconGridSelect, fix EN template seed | alexei.dolgolyov |
|
||||
| [db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5) | fix: re-create missing EN default template, provider type as IconGridSelect | alexei.dolgolyov |
|
||||
| [9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9) | feat: add provider type selector to tracking-configs, use IconGridSelect everywhere | alexei.dolgolyov |
|
||||
| [a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3) | feat: EntitySelect palette-style entity picker, replace select dropdowns | alexei.dolgolyov |
|
||||
| [86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5) | fix: search palette triggers highlight, restore CSS keyframe blink | alexei.dolgolyov |
|
||||
| [88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4) | fix: switch highlight to global store instead of URL params | alexei.dolgolyov |
|
||||
| [f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93) | fix: replace CSS keyframe highlight with direct style pulse for reliability | alexei.dolgolyov |
|
||||
| [4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40) | fix: card highlight animation — kill stagger before highlight, keep animation:none on cleanup | alexei.dolgolyov |
|
||||
| [637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467) | feat: add search button to sidebar with Ctrl+K shortcut hint | alexei.dolgolyov |
|
||||
| [4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8) | fix: prevent stagger animation replay after card highlight ends | alexei.dolgolyov |
|
||||
| [f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db) | feat: card highlight system for cross-entity navigation | alexei.dolgolyov |
|
||||
| [227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2) | fix: rename bots → telegramBots in targets page to fix undefined reference | alexei.dolgolyov |
|
||||
| [06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463) | feat: IconGridSelect, CrossLink, SearchPalette components + entity crosslinks | alexei.dolgolyov |
|
||||
| [563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f) | feat: entity cache system, nav UX improvements, split CLAUDE.md | alexei.dolgolyov |
|
||||
| [2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff) | feat: grouped nav tree with badges, dashboard events section with filtered chart | alexei.dolgolyov |
|
||||
| [ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda) | feat: remove hardcoded command templates, enforce template system exclusively | alexei.dolgolyov |
|
||||
| [3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0) | feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots | alexei.dolgolyov |
|
||||
| [846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480) | feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates | alexei.dolgolyov |
|
||||
| [371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70) | feat: fix template preview links, default chat action, update default templates | alexei.dolgolyov |
|
||||
| [1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3) | feat: entity relationship refactor — notification trackers, command system, chat actions | alexei.dolgolyov |
|
||||
| [0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f) | chore: remove accidentally committed __pycache__ | alexei.dolgolyov |
|
||||
| [03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3) | feat: telegram commands, app settings, bot polling, webhook handling, UI improvements | alexei.dolgolyov |
|
||||
| [5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37) | feat: test menu dropdown, split text/media messages, target settings, provider URL links | alexei.dolgolyov |
|
||||
| [03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66) | feat: UX & notification improvements — icons, events, chat names, link validation, templates | alexei.dolgolyov |
|
||||
| [91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5) | fix: comprehensive API/UI review — 26 bug fixes and improvements | alexei.dolgolyov |
|
||||
| [9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a) | feat: port full CRUD API routes and frontend pages from Immich Watcher | alexei.dolgolyov |
|
||||
| [c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93) | feat: port original frontend UI to Notify Bridge | alexei.dolgolyov |
|
||||
| [e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed) | fix: remove auto-redirect from API client on 401 | alexei.dolgolyov |
|
||||
| [7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6) | fix: add auth guard to root layout with setup/login redirects | alexei.dolgolyov |
|
||||
| [f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa) | fix: local fonts via @fontsource, favicon, autocomplete attrs | alexei.dolgolyov |
|
||||
| [786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e) | feat(notify-bridge): phase 9 - HAOS integration planning | alexei.dolgolyov |
|
||||
| [08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9) | feat(notify-bridge): phase 8 - integration and wiring | alexei.dolgolyov |
|
||||
| [9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7) | feat(notify-bridge): phase 7 - frontend restructuring | alexei.dolgolyov |
|
||||
| [7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89) | feat(notify-bridge): phase 6 - database models and server API | alexei.dolgolyov |
|
||||
| [16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef) | feat(notify-bridge): phase 5 - notification system | alexei.dolgolyov |
|
||||
| [f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070) | feat(notify-bridge): phase 4 - template system | alexei.dolgolyov |
|
||||
| [cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558) | feat(notify-bridge): phase 3 - Immich service provider | alexei.dolgolyov |
|
||||
| [3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c) | feat(notify-bridge): phase 2 - core abstractions | alexei.dolgolyov |
|
||||
| [b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447) | feat(notify-bridge): phase 1 - project scaffolding | alexei.dolgolyov |
|
||||
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
|
||||
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
|
||||
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | 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
-3
@@ -10,14 +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:-*}
|
||||
# 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.8.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"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.1.0",
|
||||
"version": "0.8.0",
|
||||
"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"
|
||||
}
|
||||
|
||||
+377
-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,10 +369,54 @@ 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;
|
||||
}
|
||||
|
||||
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
|
||||
Full-bleed rows that stretch to the main column width. Pair with .list-row
|
||||
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
|
||||
.list-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.list-row__identity {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
max-width: 28rem;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.list-row__identity { flex: 1 1 auto; }
|
||||
}
|
||||
.list-row__actions {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Secondary text under the name — visible only when meta-strip is hidden
|
||||
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
|
||||
.list-row__secondary {
|
||||
display: block;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.list-row__secondary { display: none; }
|
||||
}
|
||||
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
||||
@@ -193,10 +428,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 +452,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">
|
||||
|
||||
+115
-1
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
|
||||
export interface BlockedByDetail {
|
||||
message: string;
|
||||
entity: string;
|
||||
blocked_by: string[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
blockedBy?: BlockedByDetail;
|
||||
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.blockedBy = blockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
|
||||
export function parseDate(dateStr: string): Date {
|
||||
if (!dateStr) return new Date(NaN);
|
||||
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
|
||||
export function getBlockedBy(err: unknown): BlockedByDetail | null {
|
||||
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
@@ -63,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
|
||||
// and binary downloads where a 30s limit can cut off a legit slow upload.
|
||||
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
@@ -106,7 +140,17 @@ export async function api<T = any>(
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
// Structured blocked-by detail (from delete_protection.raise_if_used)
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
@@ -114,3 +158,73 @@ export async function api<T = any>(
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
|
||||
* typically multipart/form-data uploads or binary downloads where we need the
|
||||
* raw ``Response`` object rather than parsed JSON.
|
||||
*
|
||||
* - Injects the Bearer token automatically.
|
||||
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
|
||||
* decides the encoding; browsers add the boundary).
|
||||
* - Attempts a one-shot token refresh on 401, matching ``api()``.
|
||||
* - Translates non-OK responses to ``ApiError`` so callers can use the same
|
||||
* ``getBlockedBy`` / ``err.message`` handling pattern.
|
||||
*/
|
||||
export async function fetchAuth(
|
||||
path: string,
|
||||
options: RequestInit & { timeoutMs?: number } = {},
|
||||
): Promise<Response> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
|
||||
// Abort after timeout so uploads/downloads don't hang indefinitely if
|
||||
// the backend stops responding. Callers can override per-request via
|
||||
// options.timeoutMs or pass their own signal to opt out.
|
||||
const { timeoutMs, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
timeoutMs ?? DEFAULT_FETCHAUTH_TIMEOUT_MS,
|
||||
);
|
||||
const signal = options.signal ?? controller.signal;
|
||||
|
||||
try {
|
||||
let res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { BlockedByDetail } from '$lib/api';
|
||||
|
||||
let { open = false, detail = null, onclose } = $props<{
|
||||
open: boolean;
|
||||
detail: BlockedByDetail | null;
|
||||
onclose: () => void;
|
||||
}>();
|
||||
|
||||
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
|
||||
</script>
|
||||
|
||||
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
|
||||
{#if detail}
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiLinkVariant" size={20} />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
|
||||
{#if detail.entity}
|
||||
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
|
||||
{#if blockedCount > 0}
|
||||
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);">
|
||||
{blockedCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if blockedCount > 0}
|
||||
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
|
||||
{#each detail.blocked_by as consumer}
|
||||
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
|
||||
style="background: var(--color-muted); border: 1px solid var(--color-border);">
|
||||
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiChevronRight" size={14} />
|
||||
</span>
|
||||
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<button onclick={onclose} class="blocked-by-close-btn">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.blocked-by-close-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.blocked-by-close-btn:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
@@ -19,7 +20,10 @@
|
||||
noneLabel = '—',
|
||||
disabled = false,
|
||||
size = 'default',
|
||||
open = $bindable(false),
|
||||
showTrigger = true,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
items: EntityItem[];
|
||||
value: string | number | null;
|
||||
@@ -28,14 +32,16 @@
|
||||
noneLabel?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'default';
|
||||
open?: boolean;
|
||||
showTrigger?: boolean;
|
||||
onselect?: (value: string | number | null) => void;
|
||||
onclose?: () => void;
|
||||
} = $props();
|
||||
|
||||
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)));
|
||||
|
||||
@@ -51,24 +57,37 @@
|
||||
return [...result, ...matching];
|
||||
});
|
||||
|
||||
// Focus input whenever the palette transitions to open (covers both internal
|
||||
// trigger clicks and external programmatic opening via bind:open).
|
||||
let wasOpen = false;
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
query = '';
|
||||
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function openPalette() {
|
||||
if (disabled) return;
|
||||
open = true;
|
||||
query = '';
|
||||
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
// Called when the user dismisses the palette (overlay click or ESC).
|
||||
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
|
||||
function closePalette() {
|
||||
open = false;
|
||||
query = '';
|
||||
onclose?.();
|
||||
}
|
||||
|
||||
function selectItem(item: EntityItem) {
|
||||
if (item.disabled) return;
|
||||
value = item.value || null;
|
||||
onselect?.(value);
|
||||
closePalette();
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -105,71 +124,75 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Trigger button -->
|
||||
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
|
||||
{#if selected}
|
||||
{#if selected.icon}
|
||||
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
|
||||
{/if}
|
||||
<span class="es-trigger-label">{selected.label}</span>
|
||||
{:else}
|
||||
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
|
||||
{/if}
|
||||
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
||||
</button>
|
||||
|
||||
<!-- Palette overlay -->
|
||||
{#if open}
|
||||
<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-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}
|
||||
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
|
||||
{#if showTrigger}
|
||||
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
|
||||
{#if selected}
|
||||
{#if selected.icon}
|
||||
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
|
||||
{/if}
|
||||
<span class="es-trigger-label">{selected.label}</span>
|
||||
{:else}
|
||||
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
|
||||
{/if}
|
||||
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
||||
{#if open}
|
||||
<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-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 +204,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 +242,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 +308,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 +341,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 +368,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 +383,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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { parseDate } from '$lib/api';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
interface DayData {
|
||||
date: string;
|
||||
@@ -12,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> = {
|
||||
@@ -47,7 +49,7 @@
|
||||
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const d = parseDate(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
@@ -127,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;
|
||||
@@ -247,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,449 @@
|
||||
<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();
|
||||
|
||||
// Retain the last non-null event so the modal body stays populated
|
||||
// while the close transition plays after the parent clears `event`.
|
||||
let displayEvent = $state<EventLog | null>(null);
|
||||
$effect(() => {
|
||||
if (event) displayEvent = event;
|
||||
});
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
|
||||
*
|
||||
* Used by the deferred-dispatch lifecycle banner to render
|
||||
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
|
||||
* integer that the user has to mentally divide. Keeps two units so
|
||||
* the magnitude reads correctly across hours-long quiet windows
|
||||
* without becoming noisy for short ones. */
|
||||
function humanDuration(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
|
||||
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMin = minutes % 60;
|
||||
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours ? `${days}d ${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
/** Render an absolute ISO timestamp as a future-relative string.
|
||||
*
|
||||
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
|
||||
* deferred-until banner shouldn't show a relative offset once the
|
||||
* window has already ended (a follow-up event_log row marks delivery).
|
||||
*/
|
||||
function timeFromNow(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const target = new Date(iso).getTime();
|
||||
const diff = Math.floor((target - Date.now()) / 1000);
|
||||
if (diff <= 0) return '';
|
||||
return humanDuration(diff);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
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(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||
const issuerText = $derived(issuerLabel(issuer));
|
||||
|
||||
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
|
||||
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
|
||||
|
||||
const detailsJson = $derived.by(() => {
|
||||
if (!displayEvent?.details) return '';
|
||||
try {
|
||||
return JSON.stringify(displayEvent.details, null, 2);
|
||||
} catch {
|
||||
return String(displayEvent.details);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if displayEvent}
|
||||
<div class="event-detail">
|
||||
<!-- Subject + verb -->
|
||||
<div class="hero-row">
|
||||
<MdiIcon name="mdiBell" size={18} />
|
||||
<div>
|
||||
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
|
||||
<div class="hero-meta">
|
||||
<span class="event-type">{displayEvent.event_type}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDateTime(displayEvent.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispatch lifecycle (only when the event went through the
|
||||
quiet-hours defer path). Rendered ABOVE the provenance grid
|
||||
because timing of delivery is more interesting than the
|
||||
bot/tracker names when the event is held back. -->
|
||||
{#if displayEvent.details?.dispatch_status === 'deferred'}
|
||||
<section class="lifecycle lifecycle--deferred">
|
||||
<MdiIcon name="mdiPauseCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.heldUntil')}
|
||||
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
|
||||
{#if timeFromNow(displayEvent.details.deferred_until)}
|
||||
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
|
||||
<section class="lifecycle lifecycle--late">
|
||||
<MdiIcon name="mdiClockCheckOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
|
||||
{#if displayEvent.details.deferred_for_seconds != null}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.heldFor')}
|
||||
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiCloseCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
|
||||
{#if displayEvent.details.reason}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.reason')}:
|
||||
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
|
||||
{#if displayEvent.details.reason}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.reason')}:
|
||||
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiVolumeOff" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
|
||||
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Provenance grid -->
|
||||
<dl class="provenance">
|
||||
{#if displayEvent.bot_name}
|
||||
<dt>{t('events.bot')}</dt>
|
||||
<dd>{displayEvent.bot_name}</dd>
|
||||
{/if}
|
||||
{#if displayEvent.collection_id && isCommand}
|
||||
<dt>{t('events.chat')}</dt>
|
||||
<dd class="font-mono">{displayEvent.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 displayEvent.command_tracker_name}
|
||||
<dt>{t('events.commandTracker')}</dt>
|
||||
<dd>{displayEvent.command_tracker_name}</dd>
|
||||
{/if}
|
||||
{#if displayEvent.tracker_name}
|
||||
<dt>{t('events.tracker')}</dt>
|
||||
<dd>{displayEvent.tracker_name}</dd>
|
||||
{/if}
|
||||
{#if displayEvent.action_name}
|
||||
<dt>{t('events.action')}</dt>
|
||||
<dd>{displayEvent.action_name}</dd>
|
||||
{/if}
|
||||
{#if displayEvent.provider_name}
|
||||
<dt>{t('events.provider')}</dt>
|
||||
<dd>{displayEvent.provider_name}</dd>
|
||||
{/if}
|
||||
{#if displayEvent.assets_count > 0}
|
||||
<dt>{t('events.assetsCount')}</dt>
|
||||
<dd class="font-mono">{displayEvent.assets_count}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<!-- Action buttons — deep-link + highlight the related entity card -->
|
||||
<div class="actions">
|
||||
{#if displayEvent.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
|
||||
<MdiIcon name="mdiServer" size={14} />
|
||||
{t('events.openProvider')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.telegram_bot_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
|
||||
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||
{t('events.openBot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.command_tracker_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
|
||||
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||
{t('events.openAction')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCommand && !isAction && displayEvent.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
|
||||
<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); }
|
||||
|
||||
/* Dispatch lifecycle banner — appears only when the event took the
|
||||
* quiet-hours defer path. The three colour variants mirror the dashboard
|
||||
* badge palette: primary glow for "held", success for "delivered late",
|
||||
* muted/dim for "dropped" / "failed" / "suppressed".
|
||||
*/
|
||||
.lifecycle {
|
||||
display: flex; align-items: flex-start; gap: 0.7rem;
|
||||
padding: 0.75rem 0.95rem;
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.lifecycle-body {
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.lifecycle-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.lifecycle-detail {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.lifecycle-detail b {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lifecycle-rel {
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.lifecycle-hint {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.lifecycle-reason {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
|
||||
word-break: break-all;
|
||||
}
|
||||
.lifecycle--deferred {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.lifecycle--deferred :global(svg) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.lifecycle--late {
|
||||
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
|
||||
}
|
||||
.lifecycle--late :global(svg) {
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
.lifecycle--dropped {
|
||||
opacity: 0.92;
|
||||
}
|
||||
.lifecycle--dropped :global(svg) {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
|
||||
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
|
||||
|
||||
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
|
||||
|
||||
// Locales that ship with default notification & command templates.
|
||||
const SHIPPED = new Set(['en', 'ru']);
|
||||
|
||||
let {
|
||||
value = $bindable<string>(''),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// Parse the comma-separated backend string into an ordered array of codes.
|
||||
const codes = $derived.by<string[]>(() => {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of value.split(',')) {
|
||||
const c = raw.trim().toLowerCase();
|
||||
if (!c || seen.has(c)) continue;
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function commit(next: string[]) {
|
||||
// De-dupe (preserve order) and serialise back to the backend format.
|
||||
const seen = new Set<string>();
|
||||
const clean = next.map(c => c.trim().toLowerCase())
|
||||
.filter(c => c && !seen.has(c) && (seen.add(c), true));
|
||||
value = clean.join(',');
|
||||
}
|
||||
|
||||
function meta(code: string): LocaleMeta {
|
||||
return getLocaleMeta(code);
|
||||
}
|
||||
|
||||
function remove(code: string) {
|
||||
commit(codes.filter(c => c !== code));
|
||||
}
|
||||
|
||||
function makePrimary(code: string) {
|
||||
commit([code, ...codes.filter(c => c !== code)]);
|
||||
}
|
||||
|
||||
function moveUp(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i <= 0) return;
|
||||
const next = [...codes];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function moveDown(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i < 0 || i >= codes.length - 1) return;
|
||||
const next = [...codes];
|
||||
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
// --- Add flow ----------------------------------------------------------
|
||||
|
||||
// Valid BCP 47-ish: 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));
|
||||
|
||||
/**
|
||||
* Catalog languages not yet selected, surfaced through EntitySelect.
|
||||
* Native name is the label so the user sees their own script; the
|
||||
* English name + code lives in the description for searchability.
|
||||
*/
|
||||
const addItems = $derived<EntityItem[]>(
|
||||
CATALOG
|
||||
.filter(l => !selectedSet.has(l.code))
|
||||
.map(l => ({
|
||||
value: l.code,
|
||||
label: l.native,
|
||||
desc: `${l.name} · ${l.code.toUpperCase()}`,
|
||||
})),
|
||||
);
|
||||
|
||||
let customCode = $state('');
|
||||
const customCodeValid = $derived.by(() => {
|
||||
const c = customCode.trim().toLowerCase();
|
||||
if (!c || !CUSTOM_RE.test(c)) return false;
|
||||
if (selectedSet.has(c)) return false;
|
||||
if (CATALOG.some(l => l.code === c)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function addCode(code: string | number | null) {
|
||||
if (code === null) return;
|
||||
const c = String(code).trim().toLowerCase();
|
||||
if (!c) return;
|
||||
commit([...codes, c]);
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
if (!customCodeValid) return;
|
||||
addCode(customCode);
|
||||
customCode = '';
|
||||
}
|
||||
|
||||
// --- Drag & drop -------------------------------------------------------
|
||||
|
||||
let dragCode = $state<string | null>(null);
|
||||
let dragOverCode = $state<string | null>(null);
|
||||
|
||||
function onDragStart(e: DragEvent, code: string) {
|
||||
dragCode = code;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', code);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, code: string) {
|
||||
if (!dragCode || dragCode === code) return;
|
||||
e.preventDefault();
|
||||
dragOverCode = code;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, code: string) {
|
||||
e.preventDefault();
|
||||
if (!dragCode || dragCode === code) return;
|
||||
const from = codes.indexOf(dragCode);
|
||||
const to = codes.indexOf(code);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...codes];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
commit(next);
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ls-root">
|
||||
{#if codes.length === 0}
|
||||
<div class="ls-empty">
|
||||
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
|
||||
<p class="ls-empty-text">{t('locales.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="ls-list" role="list">
|
||||
{#each codes as code, i (code)}
|
||||
{@const m = meta(code)}
|
||||
{@const isPrimary = i === 0}
|
||||
{@const isShipped = SHIPPED.has(code)}
|
||||
<li
|
||||
class="ls-row"
|
||||
class:ls-row-primary={isPrimary}
|
||||
class:ls-row-dragover={dragOverCode === code}
|
||||
class:ls-row-dragging={dragCode === code}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, code)}
|
||||
ondragover={(e) => onDragOver(e, code)}
|
||||
ondrop={(e) => onDrop(e, code)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<span class="ls-rail" aria-hidden="true"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ls-handle"
|
||||
aria-label={t('locales.reorder')}
|
||||
title={t('locales.reorder')}
|
||||
tabindex="-1"
|
||||
>
|
||||
<MdiIcon name="mdiDragVertical" size={16} />
|
||||
</button>
|
||||
|
||||
<div class="ls-text">
|
||||
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
|
||||
<div class="ls-meta">
|
||||
<span class="ls-name">{m.name}</span>
|
||||
<span class="ls-dot" aria-hidden="true">·</span>
|
||||
<span class="ls-code">{code}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ls-badges">
|
||||
{#if isPrimary}
|
||||
<span class="ls-tag ls-tag-primary">
|
||||
<MdiIcon name="mdiStar" size={10} />
|
||||
{t('locales.primary')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isShipped}
|
||||
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
{t('locales.shipped')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ls-actions">
|
||||
{#if !isPrimary}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => makePrimary(code)}
|
||||
aria-label={t('locales.makePrimary')}
|
||||
title={t('locales.makePrimary')}
|
||||
>
|
||||
<MdiIcon name="mdiStarOutline" size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveUp(code)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('locales.moveUp')}
|
||||
title={t('locales.moveUp')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronUp" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveDown(code)}
|
||||
disabled={i === codes.length - 1}
|
||||
aria-label={t('locales.moveDown')}
|
||||
title={t('locales.moveDown')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronDown" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn ls-icon-danger"
|
||||
onclick={() => remove(code)}
|
||||
disabled={codes.length <= 1}
|
||||
aria-label={t('locales.remove')}
|
||||
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
|
||||
<div class="ls-add">
|
||||
<div class="ls-add-row">
|
||||
<div class="ls-add-picker">
|
||||
<EntitySelect
|
||||
items={addItems}
|
||||
value={null}
|
||||
placeholder={t('locales.add')}
|
||||
size="sm"
|
||||
onselect={addCode}
|
||||
/>
|
||||
</div>
|
||||
<div class="ls-add-custom">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={customCode}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
|
||||
placeholder={t('locales.customPlaceholder')}
|
||||
class="ls-add-custom-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-add-custom-btn"
|
||||
disabled={!customCodeValid}
|
||||
onclick={addCustom}
|
||||
title={t('locales.addCustom')}
|
||||
>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ls-hint">
|
||||
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||
<span>{t('locales.orderHint')}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ls-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Empty state -------------------------------------------------- */
|
||||
.ls-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
|
||||
transparent 60%),
|
||||
var(--color-background);
|
||||
}
|
||||
.ls-empty-glyph {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 300;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ls-empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- List --------------------------------------------------------- */
|
||||
.ls-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.ls-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ls-row:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
}
|
||||
.ls-row.ls-row-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.ls-row.ls-row-dragover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
|
||||
}
|
||||
.ls-row.ls-row-primary {
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 30%),
|
||||
var(--color-background);
|
||||
}
|
||||
|
||||
/* Accent rail — pronounced on primary, near-invisible otherwise */
|
||||
.ls-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ls-row.ls-row-primary .ls-rail {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.ls-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.4;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.ls-row:hover .ls-handle {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ls-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ls-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-native {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--color-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
font-size: 0.625rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ls-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.ls-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-tag-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground, #fff);
|
||||
}
|
||||
.ls-tag-shipped {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.ls-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
.ls-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ls-icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.ls-icon-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ---- Add zone ----------------------------------------------------- */
|
||||
.ls-add {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.ls-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-add-picker {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.ls-add-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
.ls-add-custom-input {
|
||||
width: 6rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.ls-add-custom-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ls-add-custom-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ls-add-custom-btn:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-add-custom-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Hint --------------------------------------------------------- */
|
||||
.ls-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
export type MetaTone = 'default' | 'mint' | 'sky' | 'coral' | 'citrus' | 'orchid' | 'lavender';
|
||||
|
||||
export interface MetaTile {
|
||||
icon?: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
hint?: string;
|
||||
tone?: MetaTone;
|
||||
mono?: boolean;
|
||||
href?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
copyValue?: string;
|
||||
}
|
||||
|
||||
let { tiles, align = 'start' }: {
|
||||
tiles: MetaTile[];
|
||||
align?: 'start' | 'end';
|
||||
} = $props();
|
||||
|
||||
function handleClick(e: MouseEvent, tile: MetaTile) {
|
||||
if (tile.onclick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tile.onclick(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="meta-strip" style="justify-content: {align === 'end' ? 'flex-end' : 'flex-start'};">
|
||||
{#each tiles as tile, i (i)}
|
||||
{#if tile.href}
|
||||
<a
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
href={tile.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</a>
|
||||
{:else if tile.onclick}
|
||||
<button
|
||||
type="button"
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
onclick={(e: MouseEvent) => handleClick(e, tile)}
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'}"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.meta-strip {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
|
||||
padding: 2px 18px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.meta-strip {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-tile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 22rem;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.meta-tile__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: currentColor;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-tile__text {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-tile__value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta-tile__label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-tile--mono .meta-tile__label,
|
||||
.meta-tile--mono .meta-tile__value {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-tile--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.meta-tile--interactive:hover {
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Tone variants — applied to the dot/icon and accent border on hover */
|
||||
.meta-tone-mint { box-shadow: inset 2px 0 0 var(--color-mint); }
|
||||
.meta-tone-sky { box-shadow: inset 2px 0 0 var(--color-sky); }
|
||||
.meta-tone-coral { box-shadow: inset 2px 0 0 var(--color-coral); }
|
||||
.meta-tone-citrus { box-shadow: inset 2px 0 0 var(--color-citrus); }
|
||||
.meta-tone-orchid { box-shadow: inset 2px 0 0 var(--color-orchid); }
|
||||
.meta-tone-lavender { box-shadow: inset 2px 0 0 var(--color-primary); }
|
||||
|
||||
.meta-tone-mint .meta-tile__icon { color: var(--color-mint); }
|
||||
.meta-tone-sky .meta-tile__icon { color: var(--color-sky); }
|
||||
.meta-tone-coral .meta-tile__icon { color: var(--color-coral); }
|
||||
.meta-tone-citrus .meta-tile__icon { color: var(--color-citrus); }
|
||||
.meta-tone-orchid .meta-tile__icon { color: var(--color-orchid); }
|
||||
.meta-tone-lavender .meta-tile__icon { color: var(--color-primary); }
|
||||
</style>
|
||||
@@ -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,14 +11,22 @@
|
||||
}>();
|
||||
|
||||
let visible = $state(false);
|
||||
let panelEl: HTMLDivElement;
|
||||
let mounted = $state(false);
|
||||
let panelEl = $state<HTMLDivElement | undefined>();
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const TRANSITION_MS = 250;
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
mounted = true;
|
||||
requestAnimationFrame(() => {
|
||||
visible = true;
|
||||
// Focus first focusable element inside the modal
|
||||
@@ -29,13 +37,18 @@
|
||||
focusable?.focus();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
} else if (mounted) {
|
||||
visible = false;
|
||||
// Restore focus to the previously focused element
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
mounted = false;
|
||||
closeTimer = null;
|
||||
}, TRANSITION_MS);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,87 +86,145 @@
|
||||
|
||||
<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"
|
||||
>
|
||||
{#if mounted}
|
||||
<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);
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let {
|
||||
value = $bindable<string>('UTC'),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// --- Catalog -----------------------------------------------------------
|
||||
|
||||
const timezones = $derived.by<string[]>(() => {
|
||||
try {
|
||||
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
|
||||
if (typeof intl.supportedValuesOf === 'function') {
|
||||
return intl.supportedValuesOf('timeZone');
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return ['UTC'];
|
||||
});
|
||||
|
||||
const detectedTz = (() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
||||
catch { return 'UTC'; }
|
||||
})();
|
||||
|
||||
// --- Live clock --------------------------------------------------------
|
||||
|
||||
let now = $state(new Date());
|
||||
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
tickHandle = setInterval(() => { now = new Date(); }, 1000);
|
||||
});
|
||||
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
|
||||
|
||||
function splitTz(tz: string): { region: string; city: string } {
|
||||
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
|
||||
const parts = tz.split('/');
|
||||
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
|
||||
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
|
||||
return { region, city };
|
||||
}
|
||||
|
||||
function fmtTime(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch { return '--:--:--'; }
|
||||
}
|
||||
|
||||
function fmtDate(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(now);
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function fmtOffset(tz: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(now);
|
||||
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
|
||||
return off || 'UTC';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// --- Selected state ----------------------------------------------------
|
||||
|
||||
const selected = $derived.by(() => {
|
||||
const s = splitTz(value || 'UTC');
|
||||
return {
|
||||
iana: value || 'UTC',
|
||||
region: s.region,
|
||||
city: s.city,
|
||||
time: fmtTime(value || 'UTC'),
|
||||
date: fmtDate(value || 'UTC'),
|
||||
offset: fmtOffset(value || 'UTC'),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Picker ------------------------------------------------------------
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let panelEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
|
||||
if (!q) return timezones;
|
||||
return timezones.filter(tz => tz.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Group filtered tz list by region prefix for visual hierarchy.
|
||||
interface Group { region: string; items: string[] }
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const tz of filtered) {
|
||||
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
|
||||
if (!map.has(region)) map.set(region, []);
|
||||
map.get(region)!.push(tz);
|
||||
}
|
||||
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
const ai = REGION_ORDER.indexOf(a);
|
||||
const bi = REGION_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
})
|
||||
.map(([region, items]) => ({ region, items }));
|
||||
});
|
||||
|
||||
// Flattened index for keyboard navigation.
|
||||
const flat = $derived<string[]>(groups.flatMap(g => g.items));
|
||||
|
||||
function openPicker() {
|
||||
open = true;
|
||||
query = '';
|
||||
highlightIdx = Math.max(0, flat.indexOf(value));
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus();
|
||||
scrollToHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function selectTz(tz: string) {
|
||||
value = tz;
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closePicker(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlight() {
|
||||
requestAnimationFrame(() => {
|
||||
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => { query; highlightIdx = 0; });
|
||||
|
||||
/**
|
||||
* The panel is portalled to <body> to escape Card's overflow:hidden +
|
||||
* backdrop-filter (which would otherwise clip and stacking-trap the
|
||||
* dropdown). Outside-click is detected via the dedicated overlay div
|
||||
* rather than a document listener, so we don't need a global handler.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div class="tz-root">
|
||||
<!-- Selected card -->
|
||||
<button
|
||||
type="button"
|
||||
class="tz-card"
|
||||
class:tz-card-open={open}
|
||||
onclick={() => (open ? closePicker() : openPicker())}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div class="tz-card-left">
|
||||
<div class="tz-region">{selected.region}</div>
|
||||
<div class="tz-city">{selected.city}</div>
|
||||
<div class="tz-sub">
|
||||
<span class="tz-iana">{selected.iana}</span>
|
||||
{#if selected.date}
|
||||
<span class="tz-dot">·</span>
|
||||
<span class="tz-date">{selected.date}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tz-card-right">
|
||||
<div class="tz-clock">{selected.time}</div>
|
||||
<div class="tz-offset">{selected.offset}</div>
|
||||
</div>
|
||||
<span class="tz-chev" aria-hidden="true">
|
||||
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div use:portal class="tz-portal-root">
|
||||
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
|
||||
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tz-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Selected card ------------------------------------------------ */
|
||||
.tz-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 55%),
|
||||
var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.tz-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
.tz-card.tz-card-open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.tz-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-region {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
}
|
||||
.tz-city {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-iana {
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-dot { opacity: 0.5; }
|
||||
|
||||
.tz-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.tz-clock {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1;
|
||||
/* Stable width so seconds ticker doesn't shift layout */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tz-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.tz-chev {
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
|
||||
.tz-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tz-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
|
||||
/* ---- Panel (centered modal palette) -------------------------------- */
|
||||
.tz-panel {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: min(20vh, 120px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
width: min(540px, 92vw);
|
||||
max-height: min(60vh, 30rem);
|
||||
background: var(--tz-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong, var(--color-border));
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
|
||||
0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: tz-pop 0.15s ease-out;
|
||||
--tz-solid-bg: #131520;
|
||||
}
|
||||
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
|
||||
.tz-panel::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
@keyframes tz-pop {
|
||||
from { opacity: 0; transform: translate(-50%, -3px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
.tz-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-search {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-kbd {
|
||||
font-size: 0.55rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tz-quick {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-background);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.tz-quick-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.tz-quick-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tz-list {
|
||||
overflow-y: auto;
|
||||
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;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tz-group {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.tz-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem 0.25rem;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted-foreground);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--tz-solid-bg);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-group-count {
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tz-opt {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tz-opt.tz-opt-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.tz-opt.tz-opt-sel {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.tz-opt-city {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-sel .tz-opt-city {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-opt-iana {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-hl .tz-opt-offset {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
</style>
|
||||
@@ -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[] => [
|
||||
@@ -89,6 +105,12 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
|
||||
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
|
||||
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
|
||||
{ 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) ---
|
||||
@@ -98,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[] => [
|
||||
|
||||
+418
-20
@@ -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",
|
||||
@@ -64,6 +80,8 @@
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Events",
|
||||
"clearEvents": "Clear",
|
||||
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
|
||||
"chart": "Event chart",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
@@ -76,6 +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",
|
||||
@@ -83,21 +114,102 @@
|
||||
"filterRenamed": "Renamed",
|
||||
"filterDeleted": "Deleted",
|
||||
"filterSharingChanged": "Sharing Changed",
|
||||
"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",
|
||||
"loadingEvents": "Loading events...",
|
||||
"heldUntil": "held until",
|
||||
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
|
||||
"deliveredLate": "delivered late",
|
||||
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
|
||||
"deferredThenDropped": "dropped after defer",
|
||||
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
|
||||
"deferredThenFailed": "failed after defer",
|
||||
"suppressedQuietHours": "suppressed (quiet hours)",
|
||||
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
|
||||
"asset": "asset",
|
||||
"assets": "assets",
|
||||
"eventActivity": "Event Activity",
|
||||
"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",
|
||||
"lifecycle": {
|
||||
"heldTitle": "Held by quiet hours",
|
||||
"heldUntil": "Will dispatch at",
|
||||
"heldFor": "Held for",
|
||||
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
|
||||
"inPrefix": "in",
|
||||
"deliveredLateTitle": "Delivered after quiet hours",
|
||||
"originalEvent": "Original event",
|
||||
"droppedTitle": "Dropped after defer",
|
||||
"failedTitle": "Failed after defer",
|
||||
"reason": "Reason",
|
||||
"suppressedTitle": "Suppressed by quiet hours",
|
||||
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -143,7 +255,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",
|
||||
@@ -182,7 +295,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",
|
||||
@@ -194,6 +310,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",
|
||||
@@ -240,7 +359,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",
|
||||
@@ -252,7 +372,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",
|
||||
@@ -286,6 +414,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",
|
||||
@@ -351,11 +484,20 @@
|
||||
"receiverUpdated": "Receiver updated",
|
||||
"confirmDeleteReceiver": "Delete this receiver?",
|
||||
"receiverEnabled": "Receiver enabled",
|
||||
"receiverDisabled": "Receiver disabled"
|
||||
"receiverDisabled": "Receiver disabled",
|
||||
"groupNoBot": "No bot linked",
|
||||
"groupDirect": "Direct delivery",
|
||||
"groupBotMissing": "Unknown bot",
|
||||
"target": "target",
|
||||
"targetsLower": "targets",
|
||||
"openBot": "Open bot"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "& access",
|
||||
"countLabel": "users",
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"you": "you",
|
||||
"addUser": "Add User",
|
||||
"cancel": "Cancel",
|
||||
"username": "Username",
|
||||
@@ -365,11 +507,14 @@
|
||||
"roleAdmin": "Admin",
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit user",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined",
|
||||
"noUsers": "No users found"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "bots",
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
@@ -402,6 +547,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",
|
||||
@@ -422,6 +568,8 @@
|
||||
"webhookRegistered": "Webhook registered",
|
||||
"webhookUnregistered": "Webhook unregistered",
|
||||
"updateMode": "Update mode",
|
||||
"none": "None",
|
||||
"noneActive": "Listener disabled",
|
||||
"polling": "Polling",
|
||||
"webhook": "Webhook",
|
||||
"webhookStatus": "Webhook status",
|
||||
@@ -444,6 +592,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",
|
||||
@@ -514,6 +664,9 @@
|
||||
"memorySource": "Memory source",
|
||||
"memorySourceAlbums": "Scan tracked albums",
|
||||
"memorySourceNative": "Immich native memories",
|
||||
"quietHours": "Quiet hours",
|
||||
"quietHoursStart": "Start",
|
||||
"quietHoursEnd": "End",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
@@ -536,11 +689,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",
|
||||
@@ -582,7 +745,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": {
|
||||
@@ -651,6 +821,7 @@
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "options",
|
||||
"title": "Settings",
|
||||
"description": "Global application settings",
|
||||
"general": "General",
|
||||
@@ -659,23 +830,137 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||
"cacheTtl": "URL Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
|
||||
"cacheMaxEntries": "Cache Max Entries",
|
||||
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
|
||||
"cacheStats": "Cache contents",
|
||||
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
|
||||
"cacheStatsUrl": "URL cache",
|
||||
"cacheStatsAsset": "Asset cache",
|
||||
"cacheStatsEntries": "entries",
|
||||
"cacheStatsEmpty": "empty",
|
||||
"cacheStatsOldest": "oldest",
|
||||
"cacheStatsNewest": "newest",
|
||||
"clearCache": "Clear Media Cache",
|
||||
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
|
||||
"clearCacheConfirmTitle": "Clear Telegram cache?",
|
||||
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
|
||||
"clearCacheConfirmBtn": "Clear cache",
|
||||
"clearCacheDone": "Telegram cache cleared",
|
||||
"timezone": "Timezone",
|
||||
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
||||
"locales": "Template Languages",
|
||||
"supportedLocales": "Supported Locales",
|
||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
||||
"saved": "Settings saved"
|
||||
"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",
|
||||
"identity": "Identity",
|
||||
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
|
||||
"telegramHeadline": "Webhook authentication and media cache tuning",
|
||||
"loggingHeadline": "Verbosity, output format, and per-module overrides",
|
||||
"heroNoUrl": "External URL not set",
|
||||
"heroNoLocales": "no locales",
|
||||
"copy": "Copy",
|
||||
"urlCopied": "URL copied",
|
||||
"openExternal": "Open",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"secretSet": "Verified",
|
||||
"secretUnset": "Not configured",
|
||||
"cacheConfig": "Cache",
|
||||
"cacheTtlShort": "TTL",
|
||||
"cacheMaxShort": "Max entries",
|
||||
"cacheMaxFootnote": "per bucket (LRU)",
|
||||
"hoursShort": "hrs",
|
||||
"entriesShort": "max",
|
||||
"ttlNoExpiry": "no expiry",
|
||||
"cacheCapacity": "Cache capacity",
|
||||
"cacheCapacityCap": "of {n} cap",
|
||||
"logModulePlaceholder": "module.path",
|
||||
"addOverride": "Add override",
|
||||
"removeOverride": "Remove",
|
||||
"editAsText": "Edit as text",
|
||||
"editAsChips": "Edit as chips",
|
||||
"logPreviewLabel": "ACTIVE",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"unsaved": "UNSAVED",
|
||||
"changedOne": "1 setting changed",
|
||||
"changedMany": "{n} settings changed",
|
||||
"discard": "Discard",
|
||||
"saveChanges": "Save changes",
|
||||
"release": {
|
||||
"eyebrow": "Releases",
|
||||
"headline": "Stay current with upstream",
|
||||
"provider": "Provider",
|
||||
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
|
||||
"comingSoon": "Coming soon",
|
||||
"disabled": "Disabled",
|
||||
"repository": "Repository",
|
||||
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
|
||||
"options": "Options",
|
||||
"includePrereleases": "Include pre-releases",
|
||||
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
|
||||
"interval": "Check interval",
|
||||
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
|
||||
"intervalRange": "1–168 hrs",
|
||||
"hoursUnit": "hrs",
|
||||
"testConnection": "Test connection",
|
||||
"checkNow": "Check now",
|
||||
"checkDone": "Release check complete",
|
||||
"checkFailed": "Release check failed",
|
||||
"testOk": "Provider reachable",
|
||||
"testFailed": "Provider unreachable",
|
||||
"testFound": "Provider returned",
|
||||
"viewRelease": "View v{v} release",
|
||||
"statusUpToDate": "You're up to date",
|
||||
"statusUpdate": "Update available",
|
||||
"statusDisabled": "Release checks disabled",
|
||||
"statusError": "Last check failed",
|
||||
"statusUnknown": "Not checked yet",
|
||||
"heroAvailable": "available",
|
||||
"updateAvailableTooltip": "v{v} available — open Settings",
|
||||
"lastChecked": "Last checked",
|
||||
"never": "never",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"daysAgo": "{n} d ago",
|
||||
"error": {
|
||||
"disabled": "Release checks are disabled",
|
||||
"misconfigured": "Provider not fully configured",
|
||||
"provider_changed": "Provider changed — awaiting next check",
|
||||
"no_release_found": "No matching release found upstream",
|
||||
"network_error": "Upstream unreachable",
|
||||
"http_error": "Upstream returned an error",
|
||||
"parse_error": "Upstream response could not be parsed",
|
||||
"unsafe_url": "URL rejected by safety check",
|
||||
"not_implemented": "Provider not implemented yet",
|
||||
"unknown_error": "Unknown error",
|
||||
"error": "Last check failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
|
||||
"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.",
|
||||
@@ -689,15 +974,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",
|
||||
@@ -714,6 +1005,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "accounts",
|
||||
"title": "Email Bots",
|
||||
"description": "SMTP email senders for notifications",
|
||||
"addBot": "Add Email Bot",
|
||||
@@ -733,6 +1026,8 @@
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "templates",
|
||||
"countLabel": "templates",
|
||||
"title": "Command Templates",
|
||||
"description": "Customize command response messages with Jinja2 templates",
|
||||
"newConfig": "New Config",
|
||||
@@ -742,10 +1037,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",
|
||||
@@ -767,6 +1067,7 @@
|
||||
"noTemplate": "Default (hardcoded)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "trackers",
|
||||
"title": "Command Trackers",
|
||||
"description": "Manage command trackers and their listeners",
|
||||
"newTracker": "New Tracker",
|
||||
@@ -785,13 +1086,47 @@
|
||||
"disabled": "Disabled",
|
||||
"noListeners": "No listeners attached.",
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Edit album scope",
|
||||
"scopeAll": "derived from notification routing",
|
||||
"albumsShort": "albums",
|
||||
"scopeTitle": "Album Scope Override for This Bot",
|
||||
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Search cities or IANA codes…",
|
||||
"detect": "Detect",
|
||||
"utc": "UTC",
|
||||
"noMatches": "No timezones match"
|
||||
},
|
||||
"locales": {
|
||||
"label": "language",
|
||||
"labelPlural": "languages",
|
||||
"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",
|
||||
"shipped": "Built-in",
|
||||
"shippedHint": "Default notification & command templates ship for this language.",
|
||||
"makePrimary": "Make primary",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"remove": "Remove",
|
||||
"removeLast": "At least one language is required",
|
||||
"reorder": "Drag to reorder",
|
||||
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "{count} event(s) cleared",
|
||||
"providerSaved": "Provider saved",
|
||||
"providerDeleted": "Provider deleted",
|
||||
"trackerCreated": "Tracker created",
|
||||
@@ -810,6 +1145,7 @@
|
||||
"botDeleted": "Bot deleted",
|
||||
"userCreated": "User created",
|
||||
"userDeleted": "User deleted",
|
||||
"userUpdated": "User updated",
|
||||
"passwordChanged": "Password changed",
|
||||
"copied": "Copied to clipboard",
|
||||
"genericError": "Something went wrong",
|
||||
@@ -827,6 +1163,7 @@
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed",
|
||||
"listenerScopeSaved": "Scope updated",
|
||||
"cmdTemplateSaved": "Command template saved",
|
||||
"cmdTemplateDeleted": "Command template deleted",
|
||||
"emailBotCreated": "Email bot created",
|
||||
@@ -847,7 +1184,11 @@
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"none": "None",
|
||||
@@ -952,6 +1293,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",
|
||||
@@ -960,6 +1307,17 @@
|
||||
"renamed": "Album was renamed",
|
||||
"deleted": "Album was deleted",
|
||||
"sharingChanged": "Album sharing toggled",
|
||||
"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",
|
||||
@@ -1011,6 +1369,8 @@
|
||||
"close": "close"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "automations",
|
||||
"countLabel": "actions",
|
||||
"title": "Actions",
|
||||
"description": "Scheduled mutations on external services",
|
||||
"addAction": "Add Action",
|
||||
@@ -1021,6 +1381,7 @@
|
||||
"name": "Name",
|
||||
"schedule": "Schedule",
|
||||
"interval": "Interval",
|
||||
"cronMode": "Cron expression",
|
||||
"seconds": "seconds",
|
||||
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
|
||||
"enabled": "Enabled",
|
||||
@@ -1067,6 +1428,7 @@
|
||||
"triggerScheduled": "scheduled"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "& restore",
|
||||
"title": "Backup & Restore",
|
||||
"description": "Export and import your configuration, or set up automatic backups",
|
||||
"export": "Export Configuration",
|
||||
@@ -1126,6 +1488,42 @@
|
||||
"savedFiles": "Saved Backups",
|
||||
"noFiles": "No backup files yet.",
|
||||
"download": "Download",
|
||||
"fileDeleted": "Backup file deleted"
|
||||
"fileDeleted": "Backup file deleted",
|
||||
"createManual": "Create backup",
|
||||
"manualCreated": "Backup created",
|
||||
"pendingTitle": "Restore pending — restart to apply",
|
||||
"pendingBy": "Uploaded by {by}",
|
||||
"pendingAt": "at {at}",
|
||||
"pendingCancelled": "Pending restore cancelled",
|
||||
"restorePrepared": "Restore prepared",
|
||||
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
|
||||
"applyLater": "Apply later",
|
||||
"restartNow": "Restart now",
|
||||
"restartingTitle": "Restarting backend…",
|
||||
"restartingDescription": "The page will reload once the server is back online.",
|
||||
"countLabel": "backups",
|
||||
"scheduleOn": "Auto · every {h}h",
|
||||
"scheduleOff": "Auto backup off",
|
||||
"lastBackup": "Last {ago}",
|
||||
"never": "no backups yet",
|
||||
"totalSize": "{size} total",
|
||||
"dropZone": "Drop a JSON backup here, or click to choose",
|
||||
"dropZoneActive": "Release to load",
|
||||
"changeFile": "Change file",
|
||||
"catGroupIdentity": "Identity & Routing",
|
||||
"catGroupNotif": "Notifications",
|
||||
"catGroupCmd": "Commands",
|
||||
"catGroupSystem": "System",
|
||||
"stepCategories": "What to include",
|
||||
"stepSecrets": "Secrets handling",
|
||||
"stepDownload": "Download",
|
||||
"stepFile": "Choose a file",
|
||||
"stepValidate": "Validate contents",
|
||||
"stepConflict": "On conflict",
|
||||
"stepApply": "Apply",
|
||||
"tagScheduled": "scheduled",
|
||||
"tagManual": "manual",
|
||||
"tagSecrets": "with secrets",
|
||||
"validateFirst": "Validate the file first to enable import"
|
||||
}
|
||||
}
|
||||
+417
-19
@@ -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": "Главная",
|
||||
@@ -64,6 +80,8 @@
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "События",
|
||||
"clearEvents": "Очистить",
|
||||
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
|
||||
"chart": "График событий",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
@@ -76,6 +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": "Добавление файлов",
|
||||
@@ -83,21 +114,102 @@
|
||||
"filterRenamed": "Переименование",
|
||||
"filterDeleted": "Удаление",
|
||||
"filterSharingChanged": "Изменение доступа",
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"filterCommandHandled": "Команда обработана",
|
||||
"filterCommandRateLimited": "Ограничение частоты",
|
||||
"filterCommandFailed": "Команда упала",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
"loadingEvents": "Загрузка событий...",
|
||||
"heldUntil": "ожидает до",
|
||||
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
|
||||
"deliveredLate": "доставлено позже",
|
||||
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
|
||||
"deferredThenDropped": "отброшено после задержки",
|
||||
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
|
||||
"deferredThenFailed": "ошибка после задержки",
|
||||
"suppressedQuietHours": "подавлено (тихие часы)",
|
||||
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
|
||||
"asset": "файл",
|
||||
"assets": "файлов",
|
||||
"eventActivity": "Активность событий",
|
||||
"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": "Сырые данные",
|
||||
"lifecycle": {
|
||||
"heldTitle": "Задержано тихими часами",
|
||||
"heldUntil": "Будет отправлено в",
|
||||
"heldFor": "Задержано на",
|
||||
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
|
||||
"inPrefix": "через",
|
||||
"deliveredLateTitle": "Доставлено после тихих часов",
|
||||
"originalEvent": "Исходное событие",
|
||||
"droppedTitle": "Отброшено после задержки",
|
||||
"failedTitle": "Ошибка после задержки",
|
||||
"reason": "Причина",
|
||||
"suppressedTitle": "Подавлено тихими часами",
|
||||
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Провайдеры",
|
||||
"description": "Управление подключениями к сервисам",
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
"description": "Подключения к внешним сервисам и вебхукам. Каждый провайдер кормит трекеры событиями, которые рассылаются по вашим каналам.",
|
||||
"typeSingular": "тип",
|
||||
"typePlural": "типов",
|
||||
"addProvider": "Добавить провайдер",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип провайдера",
|
||||
@@ -143,7 +255,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-сервера",
|
||||
@@ -182,6 +295,9 @@
|
||||
"cleared": "История запросов очищена"
|
||||
},
|
||||
"notificationTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"armed": "активны",
|
||||
"paused": "на паузе",
|
||||
"title": "Трекеры уведомлений",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -194,6 +310,9 @@
|
||||
"selectAlbums": "Выберите альбомы...",
|
||||
"repositories": "Репозитории",
|
||||
"selectRepositories": "Выберите репозитории...",
|
||||
"userAllowlist": "Только от пользователей",
|
||||
"userBlocklist": "Исключить пользователей",
|
||||
"selectUsers": "Выберите пользователей...",
|
||||
"boards": "Доски",
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
@@ -240,7 +359,8 @@
|
||||
"descending": "По убыванию",
|
||||
"quietHoursStart": "Тихие часы начало",
|
||||
"quietHoursEnd": "Тихие часы конец",
|
||||
"batchDuration": "Длительность пакета (секунды)",
|
||||
"adaptiveMaxSkip": "Предел адаптивного опроса",
|
||||
"adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)",
|
||||
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
||||
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
||||
"linkedTargets": "получатели",
|
||||
@@ -252,7 +372,15 @@
|
||||
"testPeriodic": "Тест периодической сводки",
|
||||
"testScheduled": "Тест запланированных фото",
|
||||
"testMemory": "Тест воспоминаний",
|
||||
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
|
||||
"checkingLinks": "Проверка ссылок...",
|
||||
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
||||
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
||||
"openTemplateConfig": "Открыть конфигурацию шаблона",
|
||||
"linkReplace": "Пересоздать",
|
||||
"linkReplacing": "Пересоздание...",
|
||||
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
||||
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
|
||||
"missingLinksTitle": "Альбомы без публичных ссылок",
|
||||
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
|
||||
"expired": "Истёк",
|
||||
@@ -286,6 +414,11 @@
|
||||
"albumDeleted": "Альбом удалён"
|
||||
},
|
||||
"targets": {
|
||||
"titleEmphasis": "канал",
|
||||
"titleEmphasisAll": "каналы",
|
||||
"receiver": "получатель",
|
||||
"receivers": "получателей",
|
||||
"channelsCount": "каналов",
|
||||
"title": "Получатели",
|
||||
"description": "Адреса доставки уведомлений",
|
||||
"descTelegram": "Чаты Telegram для доставки уведомлений",
|
||||
@@ -351,11 +484,20 @@
|
||||
"receiverUpdated": "Получатель обновлён",
|
||||
"confirmDeleteReceiver": "Удалить этого получателя?",
|
||||
"receiverEnabled": "Получатель включён",
|
||||
"receiverDisabled": "Получатель отключён"
|
||||
"receiverDisabled": "Получатель отключён",
|
||||
"groupNoBot": "Без привязки к боту",
|
||||
"groupDirect": "Прямая доставка",
|
||||
"groupBotMissing": "Неизвестный бот",
|
||||
"target": "получатель",
|
||||
"targetsLower": "получателей",
|
||||
"openBot": "Открыть бота"
|
||||
},
|
||||
"users": {
|
||||
"titleEmphasis": "и доступ",
|
||||
"countLabel": "пользователей",
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"you": "вы",
|
||||
"addUser": "Добавить пользователя",
|
||||
"cancel": "Отмена",
|
||||
"username": "Имя пользователя",
|
||||
@@ -365,11 +507,14 @@
|
||||
"roleAdmin": "Администратор",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать пользователя",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован",
|
||||
"noUsers": "Пользователи не найдены"
|
||||
},
|
||||
"telegramBot": {
|
||||
"titleEmphasis": "telegram",
|
||||
"countLabel": "ботов",
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
@@ -402,6 +547,7 @@
|
||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||
"syncCommands": "Синхр. команды",
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"discoveringChats": "Поиск чатов…",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён",
|
||||
@@ -422,6 +568,8 @@
|
||||
"webhookRegistered": "Вебхук зарегистрирован",
|
||||
"webhookUnregistered": "Вебхук удалён",
|
||||
"updateMode": "Режим обновлений",
|
||||
"none": "Откл.",
|
||||
"noneActive": "Приём обновлений отключён",
|
||||
"polling": "Опрос",
|
||||
"webhook": "Вебхук",
|
||||
"webhookStatus": "Статус вебхука",
|
||||
@@ -444,6 +592,8 @@
|
||||
"webhookFailed": "Не удалось зарегистрировать webhook"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
@@ -514,6 +664,9 @@
|
||||
"memorySource": "Источник воспоминаний",
|
||||
"memorySourceAlbums": "Сканировать альбомы",
|
||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||
"quietHours": "Тихие часы",
|
||||
"quietHoursStart": "Начало",
|
||||
"quietHoursEnd": "Конец",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
@@ -536,11 +689,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": "Название",
|
||||
@@ -582,7 +745,14 @@
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||
"invalidFormat": "Некорректная строка формата",
|
||||
"filterSlots": "Фильтр слотов...",
|
||||
"slots": "слотов"
|
||||
"slots": "слотов",
|
||||
"resetToDefault": "Сбросить к умолчанию",
|
||||
"resetAllToDefaults": "Сбросить все к умолчаниям",
|
||||
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
|
||||
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
|
||||
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
|
||||
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
|
||||
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": {
|
||||
@@ -651,6 +821,7 @@
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"settings": {
|
||||
"titleEmphasis": "параметры",
|
||||
"title": "Настройки",
|
||||
"description": "Глобальные настройки приложения",
|
||||
"general": "Общие",
|
||||
@@ -659,23 +830,137 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||
"cacheTtl": "TTL URL-кэша (часы)",
|
||||
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
|
||||
"cacheMaxEntries": "Макс. записей в кэше",
|
||||
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
|
||||
"cacheStats": "Содержимое кэша",
|
||||
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
|
||||
"cacheStatsUrl": "Кэш URL",
|
||||
"cacheStatsAsset": "Кэш ассетов",
|
||||
"cacheStatsEntries": "записей",
|
||||
"cacheStatsEmpty": "пусто",
|
||||
"cacheStatsOldest": "самая старая",
|
||||
"cacheStatsNewest": "самая свежая",
|
||||
"clearCache": "Очистить кэш медиа",
|
||||
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
|
||||
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
|
||||
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
|
||||
"clearCacheConfirmBtn": "Очистить кэш",
|
||||
"clearCacheDone": "Кэш Telegram очищен",
|
||||
"timezone": "Часовой пояс",
|
||||
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||
"locales": "Языки шаблонов",
|
||||
"supportedLocales": "Поддерживаемые локали",
|
||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||
"saved": "Настройки сохранены"
|
||||
"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": "Настройки сохранены",
|
||||
"identity": "Идентификация",
|
||||
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
|
||||
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
|
||||
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
|
||||
"heroNoUrl": "Внешний URL не задан",
|
||||
"heroNoLocales": "нет локалей",
|
||||
"copy": "Копировать",
|
||||
"urlCopied": "URL скопирован",
|
||||
"openExternal": "Открыть",
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"secretSet": "Задан",
|
||||
"secretUnset": "Не настроен",
|
||||
"cacheConfig": "Кэш",
|
||||
"cacheTtlShort": "TTL",
|
||||
"cacheMaxShort": "Макс. записей",
|
||||
"cacheMaxFootnote": "на корзину (LRU)",
|
||||
"hoursShort": "ч",
|
||||
"entriesShort": "макс",
|
||||
"ttlNoExpiry": "без срока",
|
||||
"cacheCapacity": "Заполненность кэша",
|
||||
"cacheCapacityCap": "из {n}",
|
||||
"logModulePlaceholder": "путь.модуля",
|
||||
"addOverride": "Добавить",
|
||||
"removeOverride": "Удалить",
|
||||
"editAsText": "Редактировать как текст",
|
||||
"editAsChips": "Редактировать как чипы",
|
||||
"logPreviewLabel": "АКТИВНО",
|
||||
"unsavedChanges": "Несохранённые изменения",
|
||||
"unsaved": "НЕ СОХРАНЕНО",
|
||||
"changedOne": "Изменена 1 настройка",
|
||||
"changedMany": "Изменено настроек: {n}",
|
||||
"discard": "Отменить",
|
||||
"saveChanges": "Сохранить",
|
||||
"release": {
|
||||
"eyebrow": "Релизы",
|
||||
"headline": "Следите за обновлениями",
|
||||
"provider": "Источник",
|
||||
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
|
||||
"comingSoon": "Скоро",
|
||||
"disabled": "Отключено",
|
||||
"repository": "Репозиторий",
|
||||
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
|
||||
"options": "Опции",
|
||||
"includePrereleases": "Учитывать пре-релизы",
|
||||
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
|
||||
"interval": "Интервал проверки",
|
||||
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
|
||||
"intervalRange": "1–168 ч",
|
||||
"hoursUnit": "ч",
|
||||
"testConnection": "Проверить связь",
|
||||
"checkNow": "Проверить сейчас",
|
||||
"checkDone": "Проверка релизов завершена",
|
||||
"checkFailed": "Не удалось проверить релизы",
|
||||
"testOk": "Источник доступен",
|
||||
"testFailed": "Источник недоступен",
|
||||
"testFound": "Найдена версия",
|
||||
"viewRelease": "Открыть релиз v{v}",
|
||||
"statusUpToDate": "Актуальная версия",
|
||||
"statusUpdate": "Доступно обновление",
|
||||
"statusDisabled": "Проверка релизов отключена",
|
||||
"statusError": "Ошибка последней проверки",
|
||||
"statusUnknown": "Ещё не проверялось",
|
||||
"heroAvailable": "доступна",
|
||||
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
|
||||
"lastChecked": "Последняя проверка",
|
||||
"never": "никогда",
|
||||
"justNow": "только что",
|
||||
"minutesAgo": "{n} мин назад",
|
||||
"hoursAgo": "{n} ч назад",
|
||||
"daysAgo": "{n} д назад",
|
||||
"error": {
|
||||
"disabled": "Проверка релизов отключена",
|
||||
"misconfigured": "Источник настроен не полностью",
|
||||
"provider_changed": "Источник изменён — ожидание следующей проверки",
|
||||
"no_release_found": "Подходящий релиз на источнике не найден",
|
||||
"network_error": "Источник недоступен",
|
||||
"http_error": "Источник вернул ошибку",
|
||||
"parse_error": "Не удалось разобрать ответ источника",
|
||||
"unsafe_url": "URL отклонён проверкой безопасности",
|
||||
"not_implemented": "Источник пока не реализован",
|
||||
"unknown_error": "Неизвестная ошибка",
|
||||
"error": "Ошибка последней проверки"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||
"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": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||
@@ -689,15 +974,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 бот",
|
||||
@@ -714,6 +1005,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"emailBot": {
|
||||
"titleEmphasis": "email",
|
||||
"countLabel": "учётных записей",
|
||||
"title": "Email боты",
|
||||
"description": "SMTP отправители для уведомлений по email",
|
||||
"addBot": "Добавить Email бот",
|
||||
@@ -733,6 +1026,8 @@
|
||||
"operationFailed": "Операция не удалась"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"titleEmphasis": "шаблоны",
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Шаблоны команд",
|
||||
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
||||
"newConfig": "Новый шаблон",
|
||||
@@ -742,10 +1037,15 @@
|
||||
"noConfigs": "Шаблонов команд пока нет.",
|
||||
"confirmDelete": "Удалить этот шаблон команд?",
|
||||
"commandResponses": "Ответы команд",
|
||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||
"commandErrors": "Сообщения об ошибках",
|
||||
"commandDescriptions": "Описания команд",
|
||||
"commandUsage": "Примеры использования"
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -767,6 +1067,7 @@
|
||||
"noTemplate": "По умолчанию (встроенный)"
|
||||
},
|
||||
"commandTracker": {
|
||||
"titleEmphasis": "трекеры",
|
||||
"title": "Трекеры команд",
|
||||
"description": "Управление трекерами команд и их слушателями",
|
||||
"newTracker": "Новый трекер",
|
||||
@@ -785,13 +1086,47 @@
|
||||
"disabled": "Отключён",
|
||||
"noListeners": "Нет подключённых слушателей.",
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Изменить область альбомов",
|
||||
"scopeAll": "из маршрутизации уведомлений",
|
||||
"albumsShort": "альбомов",
|
||||
"scopeTitle": "Переопределение области альбомов для этого бота",
|
||||
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||
"detect": "Определить",
|
||||
"utc": "UTC",
|
||||
"noMatches": "Нет совпадений"
|
||||
},
|
||||
"locales": {
|
||||
"label": "язык",
|
||||
"labelPlural": "языков",
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
"customPlaceholder": "или de-CH",
|
||||
"addCustom": "Добавить свой код",
|
||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||
"primary": "Основной",
|
||||
"shipped": "Встроенный",
|
||||
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
|
||||
"makePrimary": "Сделать основным",
|
||||
"moveUp": "Выше",
|
||||
"moveDown": "Ниже",
|
||||
"remove": "Удалить",
|
||||
"removeLast": "Должен быть хотя бы один язык",
|
||||
"reorder": "Перетащите для изменения порядка",
|
||||
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "Очищено событий: {count}",
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
"providerDeleted": "Провайдер удалён",
|
||||
"trackerCreated": "Трекер создан",
|
||||
@@ -810,6 +1145,7 @@
|
||||
"botDeleted": "Бот удалён",
|
||||
"userCreated": "Пользователь создан",
|
||||
"userDeleted": "Пользователь удалён",
|
||||
"userUpdated": "Пользователь обновлён",
|
||||
"passwordChanged": "Пароль изменён",
|
||||
"copied": "Скопировано",
|
||||
"genericError": "Что-то пошло не так",
|
||||
@@ -827,6 +1163,7 @@
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён",
|
||||
"listenerScopeSaved": "Область обновлена",
|
||||
"cmdTemplateSaved": "Шаблон команд сохранён",
|
||||
"cmdTemplateDeleted": "Шаблон команд удалён",
|
||||
"emailBotCreated": "Email бот создан",
|
||||
@@ -847,7 +1184,11 @@
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"hide": "Скрыть",
|
||||
"show": "Показать",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
"none": "Нет",
|
||||
@@ -952,6 +1293,12 @@
|
||||
"memorySourceNative": "Использовать API воспоминаний Immich",
|
||||
"localeEn": "Английский интерфейс",
|
||||
"localeRu": "Русский интерфейс",
|
||||
"logLevelDebug": "Подробный — каждый шаг",
|
||||
"logLevelInfo": "По умолчанию — ключевые события",
|
||||
"logLevelWarning": "Только предупреждения и ошибки",
|
||||
"logLevelError": "Только ошибки — самый тихий",
|
||||
"logFormatText": "Читаемый человеком текст",
|
||||
"logFormatJson": "Один JSON-объект на строку",
|
||||
"modeMedia": "Отправка файлов фото/видео",
|
||||
"modeText": "Только имена файлов и ссылки",
|
||||
"allEvents": "Показать все типы событий",
|
||||
@@ -960,6 +1307,17 @@
|
||||
"renamed": "Альбом переименован",
|
||||
"deleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменён доступ к альбому",
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"commandHandled": "Команда бота обработана",
|
||||
"commandRateLimited": "Команда бота ограничена по частоте",
|
||||
"commandFailed": "Команда бота вызвала ошибку",
|
||||
"refreshOff": "Автообновление выключено",
|
||||
"refresh10s": "Обновлять каждые 10 секунд",
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1011,6 +1369,8 @@
|
||||
"close": "закрыть"
|
||||
},
|
||||
"actions": {
|
||||
"titleEmphasis": "автоматизации",
|
||||
"countLabel": "действий",
|
||||
"title": "Действия",
|
||||
"description": "Запланированные операции над внешними сервисами",
|
||||
"addAction": "Добавить действие",
|
||||
@@ -1021,6 +1381,7 @@
|
||||
"name": "Название",
|
||||
"schedule": "Расписание",
|
||||
"interval": "Интервал",
|
||||
"cronMode": "Cron выражение",
|
||||
"seconds": "секунд",
|
||||
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
|
||||
"enabled": "Включено",
|
||||
@@ -1067,6 +1428,7 @@
|
||||
"triggerScheduled": "по расписанию"
|
||||
},
|
||||
"backup": {
|
||||
"titleEmphasis": "и восстановление",
|
||||
"title": "Резервное копирование",
|
||||
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
|
||||
"export": "Экспорт конфигурации",
|
||||
@@ -1126,6 +1488,42 @@
|
||||
"savedFiles": "Сохранённые бэкапы",
|
||||
"noFiles": "Файлов бэкапа пока нет.",
|
||||
"download": "Скачать",
|
||||
"fileDeleted": "Файл бэкапа удалён"
|
||||
"fileDeleted": "Файл бэкапа удалён",
|
||||
"createManual": "Создать бэкап",
|
||||
"manualCreated": "Бэкап создан",
|
||||
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
|
||||
"pendingBy": "Загружено пользователем {by}",
|
||||
"pendingAt": "в {at}",
|
||||
"pendingCancelled": "Ожидающее восстановление отменено",
|
||||
"restorePrepared": "Восстановление подготовлено",
|
||||
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
|
||||
"applyLater": "Применить позже",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"restartingTitle": "Перезапуск бэкенда…",
|
||||
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
|
||||
"countLabel": "бэкапов",
|
||||
"scheduleOn": "Авто · каждые {h}ч",
|
||||
"scheduleOff": "Авто-бэкап выключен",
|
||||
"lastBackup": "Последний {ago}",
|
||||
"never": "ещё нет бэкапов",
|
||||
"totalSize": "всего {size}",
|
||||
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
|
||||
"dropZoneActive": "Отпустите для загрузки",
|
||||
"changeFile": "Сменить файл",
|
||||
"catGroupIdentity": "Идентичность и маршрутизация",
|
||||
"catGroupNotif": "Уведомления",
|
||||
"catGroupCmd": "Команды",
|
||||
"catGroupSystem": "Система",
|
||||
"stepCategories": "Что включить",
|
||||
"stepSecrets": "Обработка секретов",
|
||||
"stepDownload": "Скачать",
|
||||
"stepFile": "Выберите файл",
|
||||
"stepValidate": "Проверить содержимое",
|
||||
"stepConflict": "При конфликте",
|
||||
"stepApply": "Применить",
|
||||
"tagScheduled": "по расписанию",
|
||||
"tagManual": "вручную",
|
||||
"tagSecrets": "с секретами",
|
||||
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
|
||||
}
|
||||
}
|
||||
@@ -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,13 +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: 'time', defaultValue: '22:00' },
|
||||
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -105,7 +120,12 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||
const warnings: { id: string; name: string; issue: string }[] = [];
|
||||
|
||||
for (const albumId of newIds) {
|
||||
// Run shared-link checks in parallel with a concurrency cap so a large
|
||||
// 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 {
|
||||
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
||||
@@ -123,6 +143,19 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
} catch { /* shared-link check failed, proceed */ }
|
||||
}
|
||||
|
||||
const queue = [...newIds];
|
||||
const workers: Promise<void>[] = [];
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
||||
workers.push((async () => {
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next === undefined) return;
|
||||
await checkOne(next);
|
||||
}
|
||||
})());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
|
||||
if (warnings.length > 0) return { warnings, proceed: false };
|
||||
return { proceed: true };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
CommandTemplateConfig,
|
||||
CommandTracker,
|
||||
Action,
|
||||
ReleaseStatus,
|
||||
} from '$lib/types';
|
||||
|
||||
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
|
||||
@@ -112,6 +113,74 @@ 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;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
/** Upstream release status — drives the sidebar badge and Settings cassette. */
|
||||
export const releaseStatusCache = (() => {
|
||||
let data = $state<ReleaseStatus | null>(null);
|
||||
let fetchedAt = $state(0);
|
||||
let inflight: Promise<ReleaseStatus | null> | null = null;
|
||||
// 5 min TTL — fresh enough that "Check now" feels instant on revisit,
|
||||
// long enough that route changes don't hammer the endpoint.
|
||||
const TTL = 300_000;
|
||||
return {
|
||||
get value() { return data; },
|
||||
invalidate() { fetchedAt = 0; },
|
||||
clear() {
|
||||
data = null;
|
||||
fetchedAt = 0;
|
||||
inflight = null;
|
||||
},
|
||||
set(next: ReleaseStatus | null) {
|
||||
data = next;
|
||||
fetchedAt = Date.now();
|
||||
},
|
||||
async fetch(force = false): Promise<ReleaseStatus | null> {
|
||||
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
data = await api<ReleaseStatus>('/settings/release');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
} catch {
|
||||
// Swallow — the badge falls back to its default "no status" state.
|
||||
return data;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
/** Supported template locales — fetched from app settings. */
|
||||
export const supportedLocalesCache = (() => {
|
||||
let data = $state<string[]>(['en', 'ru']);
|
||||
@@ -164,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Invalidate all entity caches. Useful on logout.
|
||||
*
|
||||
* Singleton state caches (release status, external URL, supported locales)
|
||||
* live outside `allCaches` because their shape differs from entity caches —
|
||||
* we clear them explicitly so a returning user as a different role can't
|
||||
* briefly see the previous user's cached payload.
|
||||
*/
|
||||
export function clearAllCaches(): void {
|
||||
Object.values(allCaches).forEach(c => c.clear());
|
||||
releaseStatusCache.clear();
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
@@ -192,6 +193,9 @@ export interface TrackingConfig {
|
||||
memory_favorite_only: boolean;
|
||||
memory_asset_type: string;
|
||||
memory_min_rating: number;
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -208,16 +212,51 @@ export interface TemplateConfig {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
|
||||
* when a notification doesn't take the immediate-deliver happy path.
|
||||
*
|
||||
* * ``deferred`` — held back by quiet hours; ``deferred_until`` carries the
|
||||
* UTC ISO datetime at which a drain job will fire.
|
||||
* * ``delivered_after_quiet_hours`` — a drain successfully dispatched the
|
||||
* originally-deferred event. ``original_event_log_id`` points back at the
|
||||
* row from when the event was first detected.
|
||||
* * ``deferred_then_dropped`` — drain time arrived but the link/target was
|
||||
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
|
||||
* * ``deferred_then_failed`` — drain dispatched but the target returned an
|
||||
* error; ``reason`` carries the truncated provider error.
|
||||
* * ``suppressed_quiet_hours_nondeferrable`` — wall-clock event type (e.g.
|
||||
* ``scheduled_message``) caught by quiet hours, dropped on principle.
|
||||
*/
|
||||
export type DispatchStatus =
|
||||
| 'deferred'
|
||||
| 'delivered_after_quiet_hours'
|
||||
| 'deferred_then_dropped'
|
||||
| 'deferred_then_failed'
|
||||
| 'suppressed_quiet_hours_nondeferrable';
|
||||
|
||||
export interface EventLog {
|
||||
id: number;
|
||||
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>;
|
||||
details: Record<string, any> & {
|
||||
dispatch_status?: DispatchStatus;
|
||||
deferred_until?: string;
|
||||
original_event_log_id?: number | null;
|
||||
deferred_for_seconds?: number;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -334,3 +373,33 @@ export interface DashboardStatus {
|
||||
recent_events: EventLog[];
|
||||
command_trackers?: number;
|
||||
}
|
||||
|
||||
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
||||
|
||||
export interface ReleaseStatus {
|
||||
provider: ReleaseProviderKind;
|
||||
current: string;
|
||||
latest: string | null;
|
||||
latest_tag: string | null;
|
||||
latest_url: string | null;
|
||||
latest_name: string | null;
|
||||
latest_body: string | null;
|
||||
latest_published_at: string | null;
|
||||
latest_prerelease: boolean;
|
||||
checked_at: string | null;
|
||||
update_available: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ReleaseTestResult {
|
||||
ok: boolean;
|
||||
info: {
|
||||
tag: string;
|
||||
version: string;
|
||||
name: string | null;
|
||||
url: string | null;
|
||||
published_at: string | null;
|
||||
prerelease: boolean;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
+603
-214
File diff suppressed because it is too large
Load Diff
+1521
-186
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
import ExecutionHistory from './ExecutionHistory.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { Action, ActionRule } from '$lib/types';
|
||||
|
||||
let allActions = $derived(actionsCache.items);
|
||||
@@ -40,7 +41,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 +81,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 +111,7 @@
|
||||
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
||||
enabled: false,
|
||||
};
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
|
||||
@@ -99,6 +123,7 @@
|
||||
schedule_interval: action.schedule_interval,
|
||||
schedule_cron: action.schedule_cron, enabled: action.enabled,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = action.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -169,9 +194,62 @@
|
||||
if (status === 'failed') return 'var(--color-error-fg)';
|
||||
return 'var(--color-muted-foreground)';
|
||||
}
|
||||
|
||||
function statusTone(status: string | undefined): MetaTile['tone'] {
|
||||
if (status === 'success') return 'mint';
|
||||
if (status === 'partial') return 'citrus';
|
||||
if (status === 'failed') return 'coral';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function actionTiles(action: Action): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push(action.enabled
|
||||
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('commandTracker.disabled'), tone: 'default' });
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: getProviderName(action.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiTagOutline',
|
||||
label: action.action_type,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: action.schedule_type === 'cron' ? 'mdiClockOutline' : 'mdiTimerOutline',
|
||||
label: formatSchedule(action),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiFormatListBulleted',
|
||||
value: String(action.rules?.length || 0),
|
||||
label: t('actions.rules'),
|
||||
tone: (action.rules?.length || 0) > 0 ? 'sky' : 'default',
|
||||
});
|
||||
if (action.last_run_status) {
|
||||
tiles.push({
|
||||
icon: 'mdiHistory',
|
||||
label: action.last_run_status,
|
||||
tone: statusTone(action.last_run_status),
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</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 +274,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 +305,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)]" />
|
||||
@@ -291,32 +369,35 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each actions as action}
|
||||
<Card hover entityId={action.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{action.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
|
||||
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
||||
<span>{formatSchedule(action)}</span>
|
||||
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
||||
{#if action.last_run_status}
|
||||
<span style="color: {statusColor(action.last_run_status)}">
|
||||
{action.last_run_status}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<p class="font-medium truncate">{action.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{action.action_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
||||
<span>{formatSchedule(action)}</span>
|
||||
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
||||
{#if action.last_run_status}
|
||||
<span style="color: {statusColor(action.last_run_status)}">
|
||||
{action.last_run_status}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={actionTiles(action)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPlay" title={t('actions.execute')}
|
||||
onclick={() => executeAction(action.id)}
|
||||
disabled={executing[action.id]} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import type { ActionExecution } from '$lib/types';
|
||||
@@ -47,14 +47,14 @@
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
if (!end) return '-';
|
||||
try {
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
const ms = parseDate(end).getTime() - parseDate(start).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} catch { return '-'; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -12,6 +13,7 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { EmailBot } from '$lib/types';
|
||||
|
||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||
@@ -29,8 +31,40 @@
|
||||
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 emailBotTiles(bot: EmailBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiEmailOutline',
|
||||
label: bot.email,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiServerNetwork',
|
||||
label: `${bot.smtp_host}:${bot.smtp_port}`,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
if (bot.smtp_use_tls) {
|
||||
tiles.push({
|
||||
icon: 'mdiLockOutline',
|
||||
label: 'TLS',
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||
@@ -38,6 +72,7 @@
|
||||
smtp_username: bot.smtp_username, smtp_password: '',
|
||||
smtp_use_tls: bot.smtp_use_tls,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editingEmail = bot.id; showEmailForm = true;
|
||||
}
|
||||
|
||||
@@ -53,17 +88,22 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
};
|
||||
@@ -80,7 +120,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>
|
||||
@@ -94,7 +141,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>
|
||||
@@ -143,16 +190,16 @@
|
||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each emailBots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||
{#if bot.smtp_use_tls}
|
||||
@@ -160,7 +207,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={emailBotTiles(bot)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
@@ -173,3 +221,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -12,6 +13,7 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { MatrixBot } from '$lib/types';
|
||||
|
||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||
@@ -28,14 +30,45 @@
|
||||
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 matrixBotTiles(bot: MatrixBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
let host = bot.homeserver_url;
|
||||
try { host = new URL(bot.homeserver_url).host; } catch { /* keep raw */ }
|
||||
tiles.push({
|
||||
icon: 'mdiServerNetwork',
|
||||
label: host,
|
||||
hint: bot.homeserver_url,
|
||||
href: bot.homeserver_url,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
if (bot.display_name) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountCircleOutline',
|
||||
label: bot.display_name,
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -51,17 +84,22 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeMatrix(id: number) {
|
||||
confirmDeleteMatrix = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDeleteMatrix = null; }
|
||||
}
|
||||
};
|
||||
@@ -78,7 +116,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>
|
||||
@@ -92,7 +137,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>
|
||||
@@ -126,23 +171,24 @@
|
||||
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each matrixBots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
|
||||
{#if bot.display_name}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={matrixBotTiles(bot)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
|
||||
@@ -155,3 +201,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
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';
|
||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -14,6 +16,7 @@
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { TelegramBot, TelegramChat } from '$lib/types';
|
||||
|
||||
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
|
||||
@@ -27,13 +30,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
|
||||
@@ -46,8 +61,38 @@
|
||||
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 telegramBotTiles(bot: TelegramBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const mode = bot.update_mode || 'none';
|
||||
const modeTone: MetaTile['tone'] = mode === 'webhook' ? 'lavender' : mode === 'polling' ? 'mint' : 'default';
|
||||
const modeLabel = mode === 'webhook' ? t('telegramBot.webhook') : mode === 'polling' ? t('telegramBot.polling') : t('telegramBot.none');
|
||||
tiles.push({
|
||||
icon: mode === 'webhook' ? 'mdiWebhook' : mode === 'polling' ? 'mdiSync' : 'mdiPowerOff',
|
||||
label: modeLabel,
|
||||
tone: modeTone,
|
||||
});
|
||||
if (bot.bot_username) {
|
||||
tiles.push({
|
||||
icon: 'mdiAt',
|
||||
label: bot.bot_username,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
const chatCount = chats[bot.id]?.length;
|
||||
if (chatCount !== undefined) {
|
||||
tiles.push({
|
||||
icon: 'mdiChat',
|
||||
value: String(chatCount),
|
||||
label: t('telegramBot.chats'),
|
||||
tone: chatCount > 0 ? 'orchid' : 'default',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -59,29 +104,35 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSection(botId: number, section: string) {
|
||||
async function toggleSection(botId: number, section: string) {
|
||||
if (expandedSection[botId] === section) {
|
||||
expandedSection = { ...expandedSection, [botId]: '' };
|
||||
return;
|
||||
}
|
||||
if (section === 'chats' && !chats[botId]) await loadChats(botId);
|
||||
else if (section === 'listeners' && !botListenerStatus[botId]) await loadListenerStatus(botId);
|
||||
expandedSection = { ...expandedSection, [botId]: section };
|
||||
if (section === 'chats') loadChats(botId);
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
@@ -91,12 +142,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) {
|
||||
@@ -278,7 +330,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>
|
||||
@@ -292,7 +351,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>
|
||||
@@ -315,35 +374,41 @@
|
||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each bots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === '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')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<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)]'
|
||||
: (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>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
|
||||
<MetaStrip tiles={telegramBotTiles(bot)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
disabled={chatsLoading[bot.id]}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
|
||||
{t('telegramBot.chats')} {chatsLoading[bot.id] ? '…' : expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
<button onclick={() => toggleSection(bot.id, 'listeners')}
|
||||
disabled={botListenerLoading[bot.id]}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
|
||||
{t('commandTracker.listeners')} {botListenerLoading[bot.id] ? '…' : expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
@@ -353,66 +418,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}
|
||||
@@ -447,6 +526,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'
|
||||
@@ -465,6 +552,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} />
|
||||
@@ -518,3 +612,74 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -20,6 +21,8 @@
|
||||
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 MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { CommandConfig } from '$lib/types';
|
||||
|
||||
function templateName(id: number | null): string {
|
||||
@@ -68,6 +71,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}[]>(
|
||||
@@ -79,6 +90,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([
|
||||
@@ -90,14 +109,75 @@
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: cfg.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const cmdCount = (cfg.enabled_commands || []).length;
|
||||
tiles.push({
|
||||
icon: 'mdiSlashForward',
|
||||
value: String(cmdCount),
|
||||
label: t('commandConfig.commands'),
|
||||
tone: cmdCount > 0 ? 'mint' : 'coral',
|
||||
});
|
||||
tiles.push({
|
||||
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
|
||||
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
|
||||
tone: 'sky',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiNumeric',
|
||||
value: String(cfg.default_count),
|
||||
label: t('commandConfig.defaultCount'),
|
||||
tone: 'citrus',
|
||||
});
|
||||
if (cfg.command_template_config_id) {
|
||||
tiles.push({
|
||||
icon: 'mdiCodeBracesBox',
|
||||
label: templateName(cfg.command_template_config_id),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
// Auto-select first matching template for the default provider_type
|
||||
// Auto-select first provider type with commands
|
||||
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
|
||||
if (types.length > 0) form.provider_type = types[0];
|
||||
// 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,
|
||||
@@ -109,6 +189,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;
|
||||
}
|
||||
@@ -132,11 +213,12 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(cfg: CommandConfig) {
|
||||
confirmDelete = {
|
||||
id: cfg.id,
|
||||
@@ -145,14 +227,26 @@
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandConfigDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</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>
|
||||
@@ -168,13 +262,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}
|
||||
@@ -199,30 +293,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}
|
||||
@@ -259,22 +353,20 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as cfg}
|
||||
<Card hover entityId={cfg.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{cfg.provider_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<div class="flex items-center gap-2 mt-0.5 list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">
|
||||
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
· {t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
· {t('commandConfig.defaultCount')}: {cfg.default_count}
|
||||
</span>
|
||||
{#if cfg.command_template_config_id}
|
||||
@@ -282,7 +374,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={commandConfigTiles(cfg)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
||||
</div>
|
||||
@@ -296,3 +389,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
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';
|
||||
@@ -18,9 +20,14 @@
|
||||
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';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
interface CmdTemplateConfig {
|
||||
id: number;
|
||||
@@ -39,6 +46,7 @@
|
||||
}
|
||||
|
||||
let LOCALES = $derived(supportedLocalesCache.items);
|
||||
let primaryLocale = $derived(LOCALES[0] || 'en');
|
||||
|
||||
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
||||
let filterText = $state('');
|
||||
@@ -53,6 +61,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>>({});
|
||||
@@ -66,7 +79,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());
|
||||
@@ -98,6 +122,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>>({});
|
||||
@@ -105,17 +137,54 @@
|
||||
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 {
|
||||
return form.slots[slotName]?.[activeLocale] || '';
|
||||
}
|
||||
|
||||
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
|
||||
function getVarsFor(slotName: string) {
|
||||
const providerVars = varsRef[form.provider_type];
|
||||
return providerVars?.[slotName] ?? varsRef[slotName];
|
||||
}
|
||||
|
||||
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
|
||||
|
||||
/** Set slot template for current locale (immutable update). */
|
||||
function setSlotValue(slotName: string, value: string) {
|
||||
form.slots = {
|
||||
@@ -126,6 +195,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([
|
||||
@@ -187,11 +263,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: config.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const slotCount = Object.keys(config.slots || {}).length;
|
||||
tiles.push({
|
||||
icon: 'mdiViewGridOutline',
|
||||
value: String(slotCount),
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
}
|
||||
if (locales.size > 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiTranslate',
|
||||
value: String(locales.size),
|
||||
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
|
||||
hint: [...locales].sort().join(', '),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
if (config.user_id === 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiShieldStarOutline',
|
||||
label: t('common.system'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
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();
|
||||
@@ -212,9 +329,10 @@
|
||||
icon: c.icon || '',
|
||||
slots: slotsCopy,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = c.id;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -242,6 +360,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)) {
|
||||
@@ -256,7 +426,7 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -265,6 +435,7 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
@@ -274,6 +445,8 @@
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -284,11 +457,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}
|
||||
@@ -302,7 +482,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>
|
||||
@@ -314,7 +494,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}
|
||||
@@ -324,76 +504,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 varsRef[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={varsRef[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')}
|
||||
@@ -424,25 +626,25 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
|
||||
{/if}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
@@ -458,13 +660,23 @@
|
||||
<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 -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
|
||||
{#if showVarsFor && modalVars}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
||||
{#each Object.entries(modalVars.variables || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
@@ -476,11 +688,19 @@
|
||||
['album_fields', 'album', 'Album fields'],
|
||||
['command_fields', 'cmd', 'Command fields'],
|
||||
['event_fields', 'event', 'Event fields'],
|
||||
['repo_fields', 'repo', 'Repository fields'],
|
||||
['issue_fields', 'issue', 'Issue fields'],
|
||||
['pr_fields', 'pr', 'Pull request fields'],
|
||||
['commit_fields', 'c', 'Commit fields'],
|
||||
['board_fields', 'board', 'Board fields'],
|
||||
['card_fields', 'card', 'Card fields'],
|
||||
['list_fields', 'lst', 'List fields'],
|
||||
['device_fields', 'd', 'Device fields'],
|
||||
] as [fieldKey, prefix, title]}
|
||||
{#if varsRef[showVarsFor][fieldKey]}
|
||||
{#if modalVars[fieldKey]}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
|
||||
{#each Object.entries(modalVars[fieldKey]) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
|
||||
@@ -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';
|
||||
@@ -11,6 +12,7 @@
|
||||
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 IconButton from '$lib/components/IconButton.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
@@ -19,6 +21,7 @@
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { providerDefaultIcon } from '$lib/grid-items';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||
|
||||
let allCmdTrackers = $state<any[]>([]);
|
||||
@@ -59,6 +62,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(() => {
|
||||
@@ -71,7 +82,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([
|
||||
@@ -83,7 +111,38 @@
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
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,
|
||||
@@ -92,6 +151,7 @@
|
||||
command_config_id: trk.command_config_id,
|
||||
enabled: trk.enabled,
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = trk.id;
|
||||
showForm = true;
|
||||
}
|
||||
@@ -107,7 +167,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; }
|
||||
}
|
||||
@@ -178,15 +238,78 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
// Per-listener album scope editing
|
||||
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
|
||||
async function openScopeEditor(trkId: number, listener: any) {
|
||||
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
|
||||
if (!trk) return;
|
||||
let collections: any[] = [];
|
||||
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
|
||||
scopeEditor = {
|
||||
trkId,
|
||||
listener,
|
||||
providerId: trk.provider_id,
|
||||
collections,
|
||||
selectedIds: [...(listener.allowed_album_ids || [])],
|
||||
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
|
||||
};
|
||||
}
|
||||
async function saveScope() {
|
||||
if (!scopeEditor) return;
|
||||
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
|
||||
try {
|
||||
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
|
||||
method: 'PATCH', body: JSON.stringify(body),
|
||||
});
|
||||
snackSuccess(t('snack.listenerScopeSaved'));
|
||||
await loadListeners(scopeEditor.trkId);
|
||||
scopeEditor = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
function providerName(id: number): string {
|
||||
return providers.find(p => p.id === id)?.name || '?';
|
||||
}
|
||||
function configName(id: number): string {
|
||||
return commandConfigs.find(c => c.id === id)?.name || '?';
|
||||
}
|
||||
|
||||
function commandTrackerTiles(trk: any): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push(trk.enabled
|
||||
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
|
||||
: { icon: 'mdiCloseCircle', label: t('commandTracker.disabled'), tone: 'coral' });
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: providerName(trk.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiCog',
|
||||
label: configName(trk.command_config_id),
|
||||
tone: 'sky',
|
||||
});
|
||||
if (trk.listener_count !== undefined) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountMultipleOutline',
|
||||
value: String(trk.listener_count),
|
||||
label: t('commandTracker.listeners').toLowerCase(),
|
||||
tone: trk.listener_count > 0 ? 'orchid' : 'default',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</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>
|
||||
@@ -202,18 +325,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>
|
||||
|
||||
@@ -245,29 +368,32 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each trackers as trk}
|
||||
<Card hover entityId={trk.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{trk.name}</p>
|
||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{trk.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
|
||||
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{#if trk.listener_count !== undefined}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||
{#if trk.listener_count !== undefined}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={commandTrackerTiles(trk)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
|
||||
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
|
||||
<button onclick={() => toggleListeners(trk.id)}
|
||||
@@ -289,10 +415,18 @@
|
||||
<div class="space-y-1">
|
||||
{#each listeners[trk.id] as listener}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
|
||||
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
|
||||
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
|
||||
title={t('commandTracker.editScope')}>
|
||||
<MdiIcon name="mdiImageMultiple" size={12} />
|
||||
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
|
||||
? t('commandTracker.scopeAll')
|
||||
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
|
||||
</button>
|
||||
</div>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||
@@ -321,3 +455,59 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<!-- Per-listener album scope editor -->
|
||||
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
|
||||
{#if scopeEditor}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
|
||||
<label class="flex items-center gap-2 text-sm mb-3">
|
||||
<input type="checkbox" bind:checked={scopeEditor.inherit} />
|
||||
{t('commandTracker.scopeInherit')}
|
||||
</label>
|
||||
{#if !scopeEditor.inherit}
|
||||
{#if scopeEditor.collections.length > 0}
|
||||
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||
{t('backup.selectAll')}
|
||||
</button>
|
||||
<span aria-hidden="true">·</span>
|
||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||
{t('backup.deselectAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
|
||||
{#if scopeEditor.collections.length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
|
||||
{:else}
|
||||
{#each scopeEditor.collections as col}
|
||||
{@const cid = col.id}
|
||||
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
|
||||
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
|
||||
onchange={(e) => {
|
||||
if (!scopeEditor) return;
|
||||
const target = e.target as HTMLInputElement;
|
||||
scopeEditor.selectedIds = target.checked
|
||||
? [...scopeEditor.selectedIds, cid]
|
||||
: scopeEditor.selectedIds.filter((i) => i !== cid);
|
||||
}} />
|
||||
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<button onclick={() => scopeEditor = null}
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
@@ -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 { api } from '$lib/api';
|
||||
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';
|
||||
@@ -21,6 +22,7 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
||||
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import TrackerForm from './TrackerForm.svelte';
|
||||
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
||||
import SharedLinkModal from './SharedLinkModal.svelte';
|
||||
@@ -45,6 +47,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 +65,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 +96,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 +121,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,28 +177,68 @@
|
||||
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();
|
||||
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) {
|
||||
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);
|
||||
form.default_tracking_config_id = first?.id ?? 0;
|
||||
}
|
||||
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);
|
||||
form.default_template_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
// Auto-select first provider if any
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
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) {
|
||||
@@ -188,6 +274,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) });
|
||||
@@ -256,7 +348,7 @@
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const d = parseDate(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch (e) { console.warn('Date format error:', e); return ''; }
|
||||
}
|
||||
@@ -283,6 +375,54 @@
|
||||
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta tiles for a tracker row. Visible on lg+ in the dead middle space
|
||||
* between identity and actions. Mirrors the secondary text shown on narrow
|
||||
* screens, but as live tiles users can scan at a glance.
|
||||
*/
|
||||
function trackerTiles(tracker: Tracker): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const trkDesc = getDescriptor(getProviderType(tracker));
|
||||
// Status — armed/paused with color tone
|
||||
tiles.push(tracker.enabled
|
||||
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
|
||||
// Provider
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: getProviderName(tracker.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
const collCount = (tracker.collection_ids || []).length;
|
||||
if (collCount > 0 || !trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiFolderMultipleOutline',
|
||||
value: String(collCount),
|
||||
label: getCollectionLabel(tracker),
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
if (!trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiTimerOutline',
|
||||
value: `${tracker.scan_interval}s`,
|
||||
label: t('notificationTracker.every').trim(),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
// Linked targets
|
||||
const tgtCount = (tracker.tracker_targets || []).length;
|
||||
tiles.push({
|
||||
icon: 'mdiTarget',
|
||||
value: String(tgtCount),
|
||||
label: t('notificationTracker.linkedTargets'),
|
||||
tone: tgtCount > 0 ? 'mint' : 'coral',
|
||||
});
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
||||
const pt = getProviderType(tracker);
|
||||
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
||||
@@ -339,8 +479,19 @@
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
// The endpoint returns 200 OK with ``{success: false, error: "..."}``
|
||||
// on soft failures (missing template slot, no matching assets,
|
||||
// provider unreachable, etc.), so checking for a thrown exception
|
||||
// is not enough. Surface ``error`` as a snackError when present.
|
||||
const res = await api<{ success?: boolean; error?: string; target?: string }>(
|
||||
`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (res && res.success === false) {
|
||||
snackError(res.error || t('common.error'));
|
||||
} else {
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -362,7 +513,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>
|
||||
@@ -379,6 +538,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' }))}
|
||||
@@ -390,6 +550,7 @@
|
||||
onsave={save}
|
||||
ontoggleCollection={toggleCollection}
|
||||
{formatDate}
|
||||
onnameinput={() => nameManuallyEdited = true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -416,24 +577,30 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack 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>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium truncate">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||
</span>
|
||||
<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')}
|
||||
</p>
|
||||
<div class="list-row__secondary mt-0.5">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<MetaStrip tiles={trackerTiles(tracker)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
@@ -486,6 +653,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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
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';
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
@@ -18,10 +19,13 @@
|
||||
|
||||
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';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
||||
import type { ServiceProvider } from '$lib/types';
|
||||
|
||||
@@ -42,6 +46,91 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build meta tiles for a provider row. Filled into the dead middle space
|
||||
* on wide displays; on narrow screens the secondary text line takes over.
|
||||
*/
|
||||
function providerTiles(provider: ServiceProvider): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const h = health[provider.id];
|
||||
const provDesc = getDescriptor(provider.type);
|
||||
// Status — first tile, color-coded
|
||||
if (h === true) {
|
||||
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
|
||||
} else if (h === false) {
|
||||
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
|
||||
} else {
|
||||
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
|
||||
}
|
||||
// Type / connection address
|
||||
const cfg = provider.config as Record<string, any> | undefined;
|
||||
if (cfg?.url) {
|
||||
tiles.push({
|
||||
icon: 'mdiLinkVariant',
|
||||
label: shortenUrl(cfg.url),
|
||||
hint: cfg.url,
|
||||
href: cfg.url,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
} else if (cfg?.host) {
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: `${cfg.host}:${cfg.port || 3493}`,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook URL (copy to clipboard)
|
||||
if (provDesc?.webhookUrlPattern) {
|
||||
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
|
||||
tiles.push({
|
||||
icon: 'mdiContentCopy',
|
||||
label: t('providers.webhookUrl'),
|
||||
hint: webhookUrl,
|
||||
tone: 'orchid',
|
||||
onclick: (e) => copyWebhookUrl(e, webhookUrl),
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
|
||||
function shortenUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const segments = u.pathname.split('/').filter(Boolean);
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
return `${u.host}${tail}`;
|
||||
} catch {
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -53,7 +142,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);
|
||||
@@ -131,16 +242,29 @@
|
||||
}
|
||||
|
||||
function startDelete(provider: any) { confirmDelete = provider; }
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -160,12 +284,12 @@
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<div in:slide={{ duration: 200 }} class="list-stack">
|
||||
<Card class="mb-6">
|
||||
<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}
|
||||
@@ -209,9 +333,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}
|
||||
@@ -224,9 +354,11 @@
|
||||
{/if}
|
||||
|
||||
{#if !showForm && allProviders.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="list-stack mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -239,30 +371,43 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each providers as provider}
|
||||
{@const provDesc = getDescriptor(provider.type)}
|
||||
<Card hover entityId={provider.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{provider.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<p class="font-medium truncate">{provider.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
|
||||
</div>
|
||||
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
|
||||
<div class="list-row__secondary">
|
||||
{#if provider.config?.url}
|
||||
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
|
||||
{:else if provider.config?.host}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||
{/if}
|
||||
{#if provDesc?.webhookUrlPattern}
|
||||
{@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>
|
||||
{#if provider.config?.url}
|
||||
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
|
||||
{:else if provider.config?.host}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={providerTiles(provider)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
||||
</div>
|
||||
@@ -280,6 +425,8 @@
|
||||
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.health-dot {
|
||||
width: 10px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import type { WebhookPayloadLog } from '$lib/types';
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,101 +2,242 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
|
||||
import SettingsHero from './SettingsHero.svelte';
|
||||
import IdentityCassette from './IdentityCassette.svelte';
|
||||
import TelegramCassette from './TelegramCassette.svelte';
|
||||
import ReleaseCassette from './ReleaseCassette.svelte';
|
||||
import CacheLedger from './CacheLedger.svelte';
|
||||
import LoggingCassette from './LoggingCassette.svelte';
|
||||
import SaveBar from './SaveBar.svelte';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
external_url: string;
|
||||
telegram_webhook_secret: string;
|
||||
telegram_cache_ttl_hours: string;
|
||||
telegram_asset_cache_max_entries: string;
|
||||
supported_locales: string;
|
||||
timezone: string;
|
||||
log_level: string;
|
||||
log_format: string;
|
||||
log_levels: string;
|
||||
release_provider_kind: string;
|
||||
release_provider_url: string;
|
||||
release_provider_repo: string;
|
||||
release_include_prereleases: string;
|
||||
release_check_interval_hours: string;
|
||||
}
|
||||
|
||||
const EMPTY: Settings = {
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '720',
|
||||
telegram_asset_cache_max_entries: '5000',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
log_level: 'INFO',
|
||||
log_format: 'text',
|
||||
log_levels: '',
|
||||
release_provider_kind: 'gitea',
|
||||
release_provider_url: 'https://git.dolgolyov-family.by',
|
||||
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
|
||||
release_include_prereleases: '0',
|
||||
release_check_interval_hours: '12',
|
||||
};
|
||||
|
||||
let loaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let clearingCache = $state(false);
|
||||
let confirmClearCache = $state(false);
|
||||
let error = $state('');
|
||||
let settings = $state({
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
supported_locales: 'en,ru',
|
||||
|
||||
let settings = $state<Settings>({ ...EMPTY });
|
||||
// Snapshot of the last server-known state, used for dirty tracking.
|
||||
let baseline = $state<Settings>({ ...EMPTY });
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
// --- Dirty tracking -----------------------------------------------------
|
||||
|
||||
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
|
||||
const out: Array<keyof Settings> = [];
|
||||
for (const key of Object.keys(settings) as Array<keyof Settings>) {
|
||||
if (settings[key] !== baseline[key]) out.push(key);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const dirty = $derived(dirtyKeys.length > 0);
|
||||
|
||||
// --- Data loading -------------------------------------------------------
|
||||
|
||||
async function loadCacheStats(): Promise<void> {
|
||||
try {
|
||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||
} catch { cacheStats = null; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { loaded = true; }
|
||||
const fetched = await api<Settings>('/settings');
|
||||
settings = { ...EMPTY, ...fetched };
|
||||
baseline = { ...settings };
|
||||
await loadCacheStats();
|
||||
// Warm the release status so the cassette renders the strip on first paint.
|
||||
await releaseStatusCache.fetch();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
error = msg;
|
||||
snackError(msg);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true; error = '';
|
||||
// --- Actions ------------------------------------------------------------
|
||||
|
||||
async function save(): Promise<void> {
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
||||
const next = await api<Settings>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
settings = { ...EMPTY, ...next };
|
||||
baseline = { ...settings };
|
||||
externalUrlCache.invalidate();
|
||||
// Release config may have changed → drop the cached status and
|
||||
// refetch so the sidebar badge + cassette strip reflect the
|
||||
// freshly-rescheduled probe without waiting for the next route
|
||||
// change to trigger another read.
|
||||
releaseStatusCache.invalidate();
|
||||
void releaseStatusCache.fetch(true);
|
||||
snackSuccess(t('settings.saved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
saving = false;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||
error = msg;
|
||||
snackError(msg);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function discard(): void {
|
||||
settings = { ...baseline };
|
||||
}
|
||||
|
||||
async function clearTelegramCache(): Promise<void> {
|
||||
confirmClearCache = false;
|
||||
clearingCache = true;
|
||||
try {
|
||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||
snackSuccess(t('settings.clearCacheDone'));
|
||||
await loadCacheStats();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Clear cache failed';
|
||||
snackError(msg);
|
||||
} finally {
|
||||
clearingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheMaxEntriesNum = $derived(
|
||||
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||
<SettingsHero {settings} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
<div class="space-y-6">
|
||||
<!-- General section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiCog" size={18} />
|
||||
{t('settings.general')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
|
||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Telegram section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiSend" size={18} />
|
||||
{t('settings.telegram')}
|
||||
</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.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="settings-page stagger-children">
|
||||
<IdentityCassette
|
||||
bind:externalUrl={settings.external_url}
|
||||
bind:timezone={settings.timezone}
|
||||
bind:supportedLocales={settings.supported_locales}
|
||||
/>
|
||||
|
||||
<!-- Locales section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiTranslate" size={18} />
|
||||
{t('settings.locales')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="telegram-deck">
|
||||
<TelegramCassette
|
||||
bind:webhookSecret={settings.telegram_webhook_secret}
|
||||
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
|
||||
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
|
||||
/>
|
||||
<CacheLedger
|
||||
stats={cacheStats}
|
||||
clearing={clearingCache}
|
||||
maxEntries={cacheMaxEntriesNum}
|
||||
onRefresh={loadCacheStats}
|
||||
onClear={() => (confirmClearCache = true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
<ReleaseCassette
|
||||
bind:providerKind={settings.release_provider_kind}
|
||||
bind:providerUrl={settings.release_provider_url}
|
||||
bind:providerRepo={settings.release_provider_repo}
|
||||
bind:includePrereleases={settings.release_include_prereleases}
|
||||
bind:checkIntervalHours={settings.release_check_interval_hours}
|
||||
/>
|
||||
|
||||
<LoggingCassette
|
||||
bind:logLevel={settings.log_level}
|
||||
bind:logFormat={settings.log_format}
|
||||
bind:logLevels={settings.log_levels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveBar
|
||||
{dirty}
|
||||
{saving}
|
||||
changedCount={dirtyKeys.length}
|
||||
onSave={save}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmClearCache}
|
||||
title={t('settings.clearCacheConfirmTitle')}
|
||||
message={t('settings.clearCacheConfirm')}
|
||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||
confirmIcon="mdiDeleteSweep"
|
||||
onconfirm={clearTelegramCache}
|
||||
oncancel={() => (confirmClearCache = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.telegram-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.telegram-deck { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: CacheStats | null;
|
||||
clearing: boolean;
|
||||
maxEntries: number;
|
||||
onRefresh: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function parseDate(iso: string | null): Date | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function ageTone(iso: string | null): Tone {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return 'mint';
|
||||
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||
if (hours < 48) return 'mint';
|
||||
if (hours < 24 * 7) return 'sky';
|
||||
if (hours < 24 * 30) return 'citrus';
|
||||
return 'coral';
|
||||
}
|
||||
|
||||
interface BucketRow {
|
||||
key: 'url' | 'asset';
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
data: CacheBucketStats | null;
|
||||
}
|
||||
|
||||
const buckets = $derived<BucketRow[]>([
|
||||
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
|
||||
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
|
||||
]);
|
||||
|
||||
const totalCount = $derived(
|
||||
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
|
||||
);
|
||||
const totalBytes = $derived(
|
||||
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
|
||||
);
|
||||
const fillPct = $derived.by(() => {
|
||||
const max = Math.max(1, maxEntries);
|
||||
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
|
||||
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
|
||||
void each; // explicit ack we considered both
|
||||
return Math.min(100, Math.round((top / max) * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="ledger glass">
|
||||
<header class="ledger-head">
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
|
||||
<span>{t('settings.cacheStats')}</span>
|
||||
</div>
|
||||
<div class="ledger-numbers">
|
||||
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
|
||||
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
|
||||
{#if totalBytes > 0}
|
||||
<span class="ledger-sep">·</span>
|
||||
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
|
||||
<Hint text={t('settings.cacheStatsHint')} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledger-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
onclick={onRefresh}
|
||||
aria-label={t('common.refresh', 'Refresh')}
|
||||
title={t('common.refresh', 'Refresh')}
|
||||
>
|
||||
<MdiIcon name="mdiRefresh" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Capacity meter (peak bucket vs configured cap) -->
|
||||
{#if maxEntries > 0}
|
||||
<div class="meter" aria-label={t('settings.cacheCapacity')}>
|
||||
<div class="meter-track">
|
||||
<div class="meter-fill" style="width: {fillPct}%"></div>
|
||||
</div>
|
||||
<span class="meter-text font-mono">
|
||||
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bucket rows -->
|
||||
<ol class="ledger-list">
|
||||
{#each buckets as bucket (bucket.key)}
|
||||
{@const data = bucket.data}
|
||||
{@const empty = !data || data.count === 0}
|
||||
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
|
||||
<li class="row" data-tone={tone} class:row-empty={empty}>
|
||||
<span class="row-edge" aria-hidden="true"></span>
|
||||
<span class="row-icon" aria-hidden="true">
|
||||
<MdiIcon name={bucket.icon} size={16} />
|
||||
</span>
|
||||
<div class="row-text">
|
||||
<span class="row-name">{t(bucket.labelKey)}</span>
|
||||
{#if empty}
|
||||
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
|
||||
{:else if data}
|
||||
<span class="row-meta">
|
||||
<span>
|
||||
<span class="font-mono">{data.count.toLocaleString()}</span>
|
||||
{t('settings.cacheStatsEntries')}
|
||||
</span>
|
||||
{#if data.total_size_bytes > 0}
|
||||
<span class="row-sep">·</span>
|
||||
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
{#if data.oldest}
|
||||
<span class="row-sep">·</span>
|
||||
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="row-dot" aria-hidden="true"></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<footer class="ledger-foot">
|
||||
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
|
||||
{#if clearing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDeleteSweep" size={14} />
|
||||
{/if}
|
||||
{clearing ? t('common.loading') : t('settings.clearCache')}
|
||||
</Button>
|
||||
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.ledger {
|
||||
padding: 1.4rem 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.ledger-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-summary { min-width: 0; }
|
||||
.ledger-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ledger-numbers {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
line-height: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-count {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--color-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger-count-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||
.ledger-bytes {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* --- Capacity meter --- */
|
||||
.meter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.meter-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
|
||||
border-radius: inherit;
|
||||
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
|
||||
}
|
||||
.meter-text {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-muted-foreground);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Bucket rows --- */
|
||||
.ledger-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.row.row-empty { opacity: 0.78; }
|
||||
|
||||
.row-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||
|
||||
.row-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-glass);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
|
||||
|
||||
.row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.row-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.row-sep { opacity: 0.45; }
|
||||
|
||||
.row-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
|
||||
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
|
||||
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
|
||||
|
||||
/* --- Footer --- */
|
||||
.ledger-foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.4rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
.foot-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row, .meter-fill { transition: none !important; }
|
||||
.row:hover { transform: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface Props {
|
||||
externalUrl: string;
|
||||
timezone: string;
|
||||
supportedLocales: string;
|
||||
}
|
||||
|
||||
let {
|
||||
externalUrl = $bindable(),
|
||||
timezone = $bindable(),
|
||||
supportedLocales = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function copyUrl(): void {
|
||||
if (!externalUrl) return;
|
||||
try {
|
||||
navigator.clipboard.writeText(externalUrl);
|
||||
copied = true;
|
||||
snackSuccess(t('settings.urlCopied'));
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => { copied = false; }, 1600);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function isReachable(url: string): boolean {
|
||||
if (!url) return false;
|
||||
try { new URL(url); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const urlValid = $derived(isReachable(externalUrl));
|
||||
</script>
|
||||
|
||||
<section class="identity glass">
|
||||
<header class="identity-head">
|
||||
<div class="identity-eyebrow">
|
||||
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
|
||||
<span>{t('settings.identity')}</span>
|
||||
</div>
|
||||
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="identity-body">
|
||||
<!-- External URL row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">01</span>
|
||||
<label for="settings-external-url" class="row-name">
|
||||
{t('settings.externalUrl')}
|
||||
<Hint text={t('settings.externalUrlHint')} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
|
||||
<span class="url-leading" aria-hidden="true">
|
||||
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
|
||||
</span>
|
||||
<input
|
||||
id="settings-external-url"
|
||||
bind:value={externalUrl}
|
||||
placeholder="https://notify.example.com"
|
||||
class="url-input"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if externalUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="url-action"
|
||||
onclick={copyUrl}
|
||||
aria-label={t('settings.copy')}
|
||||
title={t('settings.copy')}
|
||||
>
|
||||
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
|
||||
</button>
|
||||
{#if urlValid}
|
||||
<a
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="url-action"
|
||||
aria-label={t('settings.openExternal')}
|
||||
title={t('settings.openExternal')}
|
||||
>
|
||||
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timezone row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">02</span>
|
||||
<span class="row-name">
|
||||
{t('settings.timezone')}
|
||||
<Hint text={t('settings.timezoneHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<TimezoneSelector bind:value={timezone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locales row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">03</span>
|
||||
<span class="row-name">
|
||||
{t('settings.supportedLocales')}
|
||||
<Hint text={t('settings.supportedLocalesHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<LocaleSelector bind:value={supportedLocales} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.identity {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.identity-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.identity-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.identity-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.identity-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 1.4rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||
.row:last-child { padding-bottom: 0.1rem; }
|
||||
|
||||
.row-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.row-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.row-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- URL field with leading icon and trailing actions --- */
|
||||
.url-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 34rem;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-input-bg);
|
||||
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||
}
|
||||
.url-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.url-field-valid {
|
||||
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
|
||||
}
|
||||
.url-leading {
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.url-field-valid .url-leading { color: var(--color-mint); }
|
||||
.url-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.url-input::placeholder { color: var(--color-muted-foreground); }
|
||||
.url-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.url-action:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
.row-label { padding-top: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.url-field, .url-action { transition: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
||||
|
||||
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
||||
|
||||
interface Override {
|
||||
module: string;
|
||||
level: Level;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logLevel: string;
|
||||
logFormat: string;
|
||||
logLevels: string;
|
||||
}
|
||||
|
||||
let {
|
||||
logLevel = $bindable(),
|
||||
logFormat = $bindable(),
|
||||
logLevels = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
||||
const LEVEL_TONE: Record<Level, string> = {
|
||||
DEBUG: 'sky',
|
||||
INFO: 'mint',
|
||||
WARNING: 'citrus',
|
||||
ERROR: 'coral',
|
||||
};
|
||||
|
||||
let rawMode = $state(false);
|
||||
|
||||
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
|
||||
function parse(csv: string): Override[] {
|
||||
if (!csv) return [];
|
||||
const out: Override[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of csv.split(',')) {
|
||||
const piece = raw.trim();
|
||||
if (!piece) continue;
|
||||
const eq = piece.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const module = piece.slice(0, eq).trim();
|
||||
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
|
||||
if (!module || seen.has(module)) continue;
|
||||
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
|
||||
seen.add(module);
|
||||
out.push({ module, level });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serialize(rows: Override[]): string {
|
||||
return rows
|
||||
.filter(r => r.module.trim().length > 0)
|
||||
.map(r => `${r.module.trim()}=${r.level}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
let rows = $state<Override[]>(parse(logLevels));
|
||||
let lastEmitted = $state(logLevels);
|
||||
|
||||
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
|
||||
$effect(() => {
|
||||
if (logLevels !== lastEmitted) {
|
||||
rows = parse(logLevels);
|
||||
lastEmitted = logLevels;
|
||||
}
|
||||
});
|
||||
|
||||
function commit(next: Override[]): void {
|
||||
rows = next;
|
||||
const serialized = serialize(next);
|
||||
lastEmitted = serialized;
|
||||
logLevels = serialized;
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
commit([...rows, { module: '', level: 'INFO' }]);
|
||||
}
|
||||
|
||||
function removeRow(i: number): void {
|
||||
commit(rows.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function updateModule(i: number, value: string): void {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function updateLevel(i: number, level: Level): void {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
|
||||
commit(next);
|
||||
}
|
||||
|
||||
const previewLine = $derived.by(() => {
|
||||
const root = (logLevel || 'INFO').toUpperCase();
|
||||
if (rows.length === 0) return `root=${root}`;
|
||||
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="logging glass">
|
||||
<header class="log-head">
|
||||
<div class="log-eyebrow">
|
||||
<MdiIcon name="mdiTextBoxOutline" size={12} />
|
||||
<span>{t('settings.logging')}</span>
|
||||
</div>
|
||||
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<!-- Level + format -->
|
||||
<div class="log-row">
|
||||
<div class="log-cell">
|
||||
<span class="log-label">
|
||||
{t('settings.logLevel')}
|
||||
<Hint text={t('settings.logLevelHint')} />
|
||||
</span>
|
||||
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
|
||||
</div>
|
||||
<div class="log-cell">
|
||||
<span class="log-label">
|
||||
{t('settings.logFormat')}
|
||||
<Hint text={t('settings.logFormatHint')} />
|
||||
</span>
|
||||
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-module overrides -->
|
||||
<div class="overrides">
|
||||
<div class="overrides-head">
|
||||
<span class="log-label">
|
||||
{t('settings.logLevels')}
|
||||
<Hint text={t('settings.logLevelsHint')} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-toggle"
|
||||
onclick={() => (rawMode = !rawMode)}
|
||||
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
|
||||
>
|
||||
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
|
||||
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if rawMode}
|
||||
<input
|
||||
bind:value={logLevels}
|
||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||
class="raw-input"
|
||||
/>
|
||||
{:else}
|
||||
<div class="chip-stack">
|
||||
{#each rows as row, i (i)}
|
||||
{@const tone = LEVEL_TONE[row.level]}
|
||||
<div class="chip" data-tone={tone}>
|
||||
<span class="chip-edge" aria-hidden="true"></span>
|
||||
<input
|
||||
value={row.module}
|
||||
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder={t('settings.logModulePlaceholder')}
|
||||
class="chip-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="chip-sep" aria-hidden="true">=</span>
|
||||
<select
|
||||
value={row.level}
|
||||
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
|
||||
class="chip-level"
|
||||
aria-label={t('settings.logLevel')}
|
||||
>
|
||||
{#each LEVELS as lvl}
|
||||
<option value={lvl}>{lvl}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
onclick={() => removeRow(i)}
|
||||
aria-label={t('settings.removeOverride')}
|
||||
title={t('settings.removeOverride')}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="chip-add" onclick={addRow}>
|
||||
<MdiIcon name="mdiPlus" size={13} />
|
||||
<span>{t('settings.addOverride')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Live preview -->
|
||||
<div class="preview" role="status">
|
||||
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
|
||||
<code class="preview-text">{previewLine}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.logging {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
.log-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.log-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.log-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
|
||||
}
|
||||
.log-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.log-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* --- Overrides editor --- */
|
||||
.overrides {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.overrides-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.mode-toggle:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.raw-input {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
.chip-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.chip {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.18s, background 0.18s;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.chip-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
|
||||
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
|
||||
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
|
||||
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
|
||||
|
||||
.chip-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.35rem 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
|
||||
|
||||
.chip-sep {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.5;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.chip-level {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass);
|
||||
color: var(--color-foreground);
|
||||
min-width: 7.2rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
|
||||
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.chip-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--color-rule-strong);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.chip-add:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-style: solid;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
|
||||
}
|
||||
|
||||
/* --- Live preview --- */
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.preview-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-foreground);
|
||||
word-break: break-all;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,698 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
// All five fields are persisted as strings via the /settings PUT —
|
||||
// the parent owns the boundary type. Bool flags use "0" / "1".
|
||||
providerKind: string;
|
||||
providerUrl: string;
|
||||
providerRepo: string;
|
||||
includePrereleases: string;
|
||||
checkIntervalHours: string;
|
||||
}
|
||||
|
||||
let {
|
||||
providerKind = $bindable(),
|
||||
providerUrl = $bindable(),
|
||||
providerRepo = $bindable(),
|
||||
includePrereleases = $bindable(),
|
||||
checkIntervalHours = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let checking = $state(false);
|
||||
let testing = $state(false);
|
||||
let testResult = $state<ReleaseTestResult | null>(null);
|
||||
|
||||
const status = $derived(releaseStatusCache.value);
|
||||
const prereleaseChecked = $derived(includePrereleases === '1');
|
||||
const isDisabled = $derived(providerKind === 'disabled');
|
||||
|
||||
// Stale Test-result on input change is misleading — wipe whenever any of
|
||||
// the probed parameters change so the strip reflects "current" state.
|
||||
$effect(() => {
|
||||
// Touch each parameter to register dependency.
|
||||
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
|
||||
testResult = null;
|
||||
});
|
||||
|
||||
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
|
||||
|
||||
const stateTone: Tone = $derived.by(() => {
|
||||
if (!status) return 'sky';
|
||||
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
|
||||
if (status.update_available) return 'citrus';
|
||||
if (status.provider === 'disabled') return 'sky';
|
||||
return 'mint';
|
||||
});
|
||||
|
||||
const stateLabel = $derived.by(() => {
|
||||
if (!status) return t('settings.release.statusUnknown');
|
||||
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
|
||||
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
|
||||
if (status.update_available) return t('settings.release.statusUpdate');
|
||||
if (status.latest) return t('settings.release.statusUpToDate');
|
||||
return t('settings.release.statusUnknown');
|
||||
});
|
||||
|
||||
// Map backend error taxonomy → localized text. Falls back to the raw code
|
||||
// only when the key is missing (so a new server code surfaces something).
|
||||
function localizedError(code: string | null): string {
|
||||
if (!code) return '';
|
||||
const key = `settings.release.error.${code}`;
|
||||
const localized = t(key);
|
||||
// `t` falls back to the key itself when missing — detect by exact match.
|
||||
return localized === key ? code : localized;
|
||||
}
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return t('settings.release.never');
|
||||
const then = Date.parse(iso);
|
||||
if (!Number.isFinite(then)) return t('settings.release.never');
|
||||
const diff = Date.now() - then;
|
||||
const min = Math.round(diff / 60_000);
|
||||
if (min < 1) return t('settings.release.justNow');
|
||||
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
|
||||
const h = Math.round(min / 60);
|
||||
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
|
||||
const d = Math.round(h / 24);
|
||||
return t('settings.release.daysAgo').replace('{n}', String(d));
|
||||
}
|
||||
|
||||
function setProvider(kind: ReleaseProviderKind): void {
|
||||
providerKind = kind;
|
||||
}
|
||||
|
||||
function onIntervalInput(e: Event): void {
|
||||
// The native input emits string values; we keep the contract by
|
||||
// re-coercing to string before assigning to the bindable prop.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
|
||||
}
|
||||
|
||||
async function checkNow(): Promise<void> {
|
||||
checking = true;
|
||||
try {
|
||||
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
|
||||
releaseStatusCache.set(next);
|
||||
snackSuccess(t('settings.release.checkDone'));
|
||||
} catch (err: unknown) {
|
||||
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(): Promise<void> {
|
||||
testing = true;
|
||||
testResult = null;
|
||||
try {
|
||||
testResult = await api<ReleaseTestResult>('/settings/release/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
provider_kind: providerKind,
|
||||
provider_url: providerUrl,
|
||||
provider_repo: providerRepo,
|
||||
include_prereleases: prereleaseChecked,
|
||||
}),
|
||||
});
|
||||
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
|
||||
else snackError(t('settings.release.testFailed'));
|
||||
} catch (err: unknown) {
|
||||
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rel glass" id="release">
|
||||
<header class="rel-head">
|
||||
<div class="rel-eyebrow">
|
||||
<MdiIcon name="mdiUpdate" size={12} />
|
||||
<span>{t('settings.release.eyebrow')}</span>
|
||||
</div>
|
||||
<h3 class="rel-title">{t('settings.release.headline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="rel-body">
|
||||
<!-- 01 Provider — native radios for free keyboard a11y. -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">01</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.provider')}
|
||||
<Hint text={t('settings.release.providerHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
|
||||
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="gitea"
|
||||
checked={providerKind === 'gitea'}
|
||||
onchange={() => setProvider('gitea')}
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
|
||||
</label>
|
||||
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="github"
|
||||
disabled
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
|
||||
</label>
|
||||
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="disabled"
|
||||
checked={providerKind === 'disabled'}
|
||||
onchange={() => setProvider('disabled')}
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 Repository -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">02</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.repository')}
|
||||
<Hint text={t('settings.release.repositoryHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control repo-grid">
|
||||
<input
|
||||
bind:value={providerUrl}
|
||||
placeholder="https://git.example.com"
|
||||
class="text-input"
|
||||
type="url"
|
||||
spellcheck="false"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<input
|
||||
bind:value={providerRepo}
|
||||
placeholder="owner/repo"
|
||||
class="text-input mono"
|
||||
spellcheck="false"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 Options — slider toggle for include-prereleases. -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">03</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.options')}
|
||||
<Hint text={t('settings.release.prereleasesHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
class:toggle-disabled={isDisabled}
|
||||
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
|
||||
aria-pressed={prereleaseChecked}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 Check interval -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">04</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.interval')}
|
||||
<Hint text={t('settings.release.intervalHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control interval">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={checkIntervalHours}
|
||||
oninput={onIntervalInput}
|
||||
class="text-input num"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span class="unit">{t('settings.release.hoursUnit')}</span>
|
||||
<span class="footnote">{t('settings.release.intervalRange')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State strip -->
|
||||
<footer class="strip" data-tone={stateTone}>
|
||||
<div class="strip-left">
|
||||
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
|
||||
<div class="strip-text">
|
||||
<div class="strip-state">{stateLabel}</div>
|
||||
<div class="strip-meta">
|
||||
<span class="versions">
|
||||
<span class="v-current">v{status?.current ?? '—'}</span>
|
||||
{#if status?.latest && status.latest !== status.current}
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="v-latest"
|
||||
class:v-latest-update={status.update_available}
|
||||
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="sep" aria-hidden="true">·</span>
|
||||
<span class="checked">
|
||||
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
|
||||
<div class="strip-error">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if testResult && !testResult.ok}
|
||||
<div class="strip-error">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
|
||||
{localizedError(testResult.error)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if testResult && testResult.ok && testResult.info}
|
||||
<div class="strip-test-ok">
|
||||
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
|
||||
<span class="mono">v{testResult.info.version}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-actions">
|
||||
{#if status?.update_available && status.latest_url}
|
||||
<a
|
||||
class="strip-btn strip-btn-cta"
|
||||
href={status.latest_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-btn"
|
||||
onclick={testProvider}
|
||||
disabled={testing || isDisabled || !providerRepo}
|
||||
>
|
||||
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
|
||||
<span>{t('settings.release.testConnection')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="strip-btn strip-btn-primary"
|
||||
onclick={checkNow}
|
||||
disabled={checking || isDisabled}
|
||||
>
|
||||
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
|
||||
<span>{t('settings.release.checkNow')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rel {
|
||||
padding: 1.5rem 1.6rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rel-head { position: relative; z-index: 1; }
|
||||
.rel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.rel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.rel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 1.4rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||
.row-dim { opacity: 0.55; }
|
||||
.row-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.row-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.row-control { min-width: 0; }
|
||||
|
||||
/* Segmented provider control — uses real radios so arrow-key + tab
|
||||
navigation just work via the browser. */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.seg-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.seg-radio {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
}
|
||||
.seg-radio:focus-visible + .seg-content {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.seg-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
transition: background 0.18s, color 0.18s;
|
||||
}
|
||||
.seg-item:hover:not(.seg-soon) .seg-content {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-glass);
|
||||
}
|
||||
.seg-active .seg-content {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-input-bg);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
.seg-soon { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* Text fields */
|
||||
.repo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-input-bg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.text-input.mono { font-family: var(--font-mono); }
|
||||
.text-input.num { max-width: 6rem; text-align: right; }
|
||||
.text-input:focus {
|
||||
outline: 0;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
/* Interval */
|
||||
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.unit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.footnote {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||
}
|
||||
.toggle-on .toggle-thumb {
|
||||
background: white;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.toggle-label-text { font-size: 0.82rem; }
|
||||
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* State strip */
|
||||
.strip {
|
||||
margin: 0 -1.6rem;
|
||||
padding: 1rem 1.6rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
|
||||
transparent
|
||||
);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
.strip[data-tone="citrus"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 10%,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
|
||||
transparent 90%
|
||||
);
|
||||
animation: aurora-shimmer 4s linear infinite;
|
||||
}
|
||||
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
|
||||
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
|
||||
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
|
||||
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
|
||||
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.strip-state {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.strip-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||
.arrow { color: var(--color-muted-foreground); }
|
||||
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
|
||||
.sep { opacity: 0.5; }
|
||||
.rel-time { color: var(--color-foreground); }
|
||||
.strip-error {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-coral, #d27a7a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.strip-test-ok {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-mint, #6fcfa6);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.strip-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s, border-color 0.18s, transform 0.18s;
|
||||
}
|
||||
.strip-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.strip-btn-primary {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
|
||||
}
|
||||
/* The CTA — high-visibility when an update is available. */
|
||||
.strip-btn-cta {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
|
||||
}
|
||||
.strip-btn-cta:hover {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
|
||||
}
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
@keyframes aurora-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.strip[data-tone="citrus"]::before { animation: none; }
|
||||
.strip-btn { transition: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
.row-label { padding-top: 0; }
|
||||
.repo-grid { grid-template-columns: 1fr; }
|
||||
.strip { flex-direction: column; align-items: stretch; }
|
||||
.strip-actions { justify-content: stretch; }
|
||||
.strip-btn { flex: 1; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
changedCount: number;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if dirty || saving}
|
||||
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
|
||||
<div class="save-bar-inner glass">
|
||||
<span class="save-edge" aria-hidden="true"></span>
|
||||
<span class="save-pulse" aria-hidden="true"></span>
|
||||
<div class="save-text">
|
||||
<span class="save-eyebrow">{t('settings.unsaved')}</span>
|
||||
<span class="save-message">
|
||||
{#if changedCount === 1}
|
||||
{t('settings.changedOne')}
|
||||
{:else}
|
||||
{t('settings.changedMany').replace('{n}', String(changedCount))}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="discard"
|
||||
onclick={onDiscard}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('settings.discard')}
|
||||
</button>
|
||||
<Button size="sm" onclick={onSave} disabled={saving}>
|
||||
{#if saving}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiContentSave" size={14} />
|
||||
{/if}
|
||||
{saving ? t('common.loading') : t('settings.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
z-index: 40;
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
|
||||
}
|
||||
.save-bar-inner {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1rem 0.7rem 1.25rem;
|
||||
max-width: min(640px, calc(100% - 1rem));
|
||||
width: 100%;
|
||||
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
|
||||
box-shadow:
|
||||
var(--shadow-card),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.save-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
|
||||
}
|
||||
.save-pulse {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-citrus);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
|
||||
animation: save-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.save-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 1rem; /* clear room for the pulse dot */
|
||||
}
|
||||
.save-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-citrus);
|
||||
}
|
||||
.save-message {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.discard {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.discard:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
.discard:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
@keyframes save-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes save-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.save-bar, .save-pulse { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
|
||||
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||
|
||||
interface Settings {
|
||||
external_url: string;
|
||||
timezone: string;
|
||||
supported_locales: string;
|
||||
log_level: string;
|
||||
log_format: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
let { settings }: Props = $props();
|
||||
|
||||
// Live tick so the timezone pill shows the current local HH:MM.
|
||||
let now = $state(new Date());
|
||||
let tick: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
|
||||
onDestroy(() => { if (tick) clearInterval(tick); });
|
||||
|
||||
function fmtClock(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz || 'UTC',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch { return '--:--'; }
|
||||
}
|
||||
|
||||
function hostFromUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
try { return new URL(url).host; }
|
||||
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
|
||||
}
|
||||
|
||||
function localeCount(csv: string): number {
|
||||
if (!csv) return 0;
|
||||
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
|
||||
}
|
||||
|
||||
const SEVERITY_TONE: Record<string, Tone> = {
|
||||
DEBUG: 'sky',
|
||||
INFO: 'mint',
|
||||
WARNING: 'citrus',
|
||||
ERROR: 'coral',
|
||||
};
|
||||
|
||||
const pills = $derived.by<HeaderPill[]>(() => {
|
||||
const out: HeaderPill[] = [];
|
||||
|
||||
const host = hostFromUrl(settings.external_url);
|
||||
out.push(host
|
||||
? { label: host, tone: 'sky' }
|
||||
: { label: t('settings.heroNoUrl') }
|
||||
);
|
||||
|
||||
const tz = settings.timezone || 'UTC';
|
||||
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
|
||||
|
||||
const locales = settings.supported_locales || '';
|
||||
const count = localeCount(locales);
|
||||
out.push({
|
||||
label: count > 0
|
||||
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
|
||||
: t('settings.heroNoLocales'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
|
||||
const lvl = (settings.log_level || 'INFO').toUpperCase();
|
||||
out.push({
|
||||
label: `${lvl} · ${settings.log_format || 'text'}`,
|
||||
tone: SEVERITY_TONE[lvl] ?? 'mint',
|
||||
});
|
||||
|
||||
const rs = releaseStatusCache.value;
|
||||
if (rs) {
|
||||
if (rs.provider === 'disabled') {
|
||||
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
|
||||
} else if (rs.error && rs.error !== 'provider_changed') {
|
||||
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
|
||||
} else if (rs.update_available && rs.latest) {
|
||||
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
|
||||
} else if (rs.latest) {
|
||||
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
emphasis={t('settings.titleEmphasis')}
|
||||
description={t('settings.description')}
|
||||
crumb={t('crumbs.systemConfiguration')}
|
||||
{pills}
|
||||
/>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
|
||||
interface Props {
|
||||
webhookSecret: string;
|
||||
cacheTtlHours: string;
|
||||
cacheMaxEntries: string;
|
||||
}
|
||||
|
||||
let {
|
||||
webhookSecret = $bindable(),
|
||||
cacheTtlHours = $bindable(),
|
||||
cacheMaxEntries = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let showSecret = $state(false);
|
||||
|
||||
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
|
||||
const ttlHours = $derived(Number(cacheTtlHours || '0'));
|
||||
const ttlIsOff = $derived(ttlHours <= 0);
|
||||
|
||||
function ttlHumanized(h: number): string {
|
||||
if (h <= 0) return t('settings.ttlNoExpiry');
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d`;
|
||||
const w = Math.round(d / 7);
|
||||
if (w < 8) return `${w}w`;
|
||||
const mo = Math.round(d / 30);
|
||||
return `${mo}mo`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="tg glass">
|
||||
<header class="tg-head">
|
||||
<div class="tg-eyebrow">
|
||||
<MdiIcon name="mdiSend" size={12} />
|
||||
<span>{t('settings.telegram')}</span>
|
||||
</div>
|
||||
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="tg-grid">
|
||||
<!-- Webhook secret column -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-num">A</span>
|
||||
<span class="col-name">
|
||||
{t('settings.webhookSecret')}
|
||||
<Hint text={t('settings.webhookSecretHint')} />
|
||||
</span>
|
||||
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
|
||||
<span class="dot"></span>
|
||||
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
|
||||
</span>
|
||||
</div>
|
||||
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input
|
||||
bind:value={webhookSecret}
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
autocomplete="off"
|
||||
placeholder={t('providers.optional')}
|
||||
class="secret-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="secret-toggle"
|
||||
onclick={() => (showSecret = !showSecret)}
|
||||
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
|
||||
title={showSecret ? t('settings.hide') : t('settings.show')}
|
||||
>
|
||||
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Cache config column -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-num">B</span>
|
||||
<span class="col-name">{t('settings.cacheConfig')}</span>
|
||||
</div>
|
||||
<div class="cache-grid">
|
||||
<label class="num-field">
|
||||
<span class="num-label">
|
||||
{t('settings.cacheTtlShort')}
|
||||
<Hint text={t('settings.cacheTtlHint')} />
|
||||
</span>
|
||||
<div class="num-row">
|
||||
<input
|
||||
bind:value={cacheTtlHours}
|
||||
type="number"
|
||||
min="0"
|
||||
max="8760"
|
||||
class="num-input"
|
||||
/>
|
||||
<span class="num-suffix">{t('settings.hoursShort')}</span>
|
||||
</div>
|
||||
<span class="num-meta" class:num-meta-off={ttlIsOff}>
|
||||
{ttlHumanized(ttlHours)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="num-field">
|
||||
<span class="num-label">
|
||||
{t('settings.cacheMaxShort')}
|
||||
<Hint text={t('settings.cacheMaxEntriesHint')} />
|
||||
</span>
|
||||
<div class="num-row">
|
||||
<input
|
||||
bind:value={cacheMaxEntries}
|
||||
type="number"
|
||||
min="100"
|
||||
max="100000"
|
||||
class="num-input"
|
||||
/>
|
||||
<span class="num-suffix">{t('settings.entriesShort')}</span>
|
||||
</div>
|
||||
<span class="num-meta">
|
||||
{t('settings.cacheMaxFootnote')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tg {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.tg-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tg-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.tg-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.tg-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.col-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.col-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.col-status {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.col-status .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
}
|
||||
.col-status[data-state="set"] {
|
||||
color: var(--color-mint);
|
||||
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.col-status[data-state="set"] .dot {
|
||||
background: var(--color-mint);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
|
||||
}
|
||||
|
||||
/* --- Secret field --- */
|
||||
.secret-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-input-bg);
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.secret-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.secret-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.secret-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.secret-toggle:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* --- Cache config grid --- */
|
||||
.cache-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.num-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.7rem 0.85rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.num-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.num-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.num-input {
|
||||
width: 100%;
|
||||
padding: 0.1rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.1;
|
||||
outline: none;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.num-input::-webkit-outer-spin-button,
|
||||
.num-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.num-suffix {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.num-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-mint);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.num-meta-off {
|
||||
color: var(--color-citrus);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cache-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, fetchAuth } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
// --- Export state ---
|
||||
let exportSecrets = $state('exclude');
|
||||
let exporting = $state(false);
|
||||
import BackupHero from './BackupHero.svelte';
|
||||
import PendingStrip from './PendingStrip.svelte';
|
||||
import ExportPanel from './ExportPanel.svelte';
|
||||
import ImportPanel from './ImportPanel.svelte';
|
||||
import ScheduleCassette from './ScheduleCassette.svelte';
|
||||
import BackupLedger from './BackupLedger.svelte';
|
||||
|
||||
const categories = [
|
||||
{ key: 'providers', label: 'backup.catProviders' },
|
||||
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
||||
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
||||
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
||||
{ key: 'targets', label: 'backup.catTargets' },
|
||||
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
||||
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
||||
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
||||
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
||||
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
||||
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
||||
{ key: 'actions', label: 'backup.catActions' },
|
||||
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
||||
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduledSettings {
|
||||
backup_scheduled_enabled: string;
|
||||
backup_scheduled_interval_hours: string;
|
||||
backup_secrets_mode: string;
|
||||
backup_retention_count: string;
|
||||
}
|
||||
|
||||
interface PendingState {
|
||||
pending: boolean;
|
||||
uploaded_at?: string | null;
|
||||
uploaded_by?: string | null;
|
||||
conflict_mode?: string;
|
||||
supervised?: boolean;
|
||||
}
|
||||
|
||||
const allCategories = [
|
||||
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
|
||||
'tracking_configs', 'template_configs',
|
||||
'command_configs', 'command_template_configs',
|
||||
'notification_trackers', 'command_trackers',
|
||||
'actions', 'app_settings',
|
||||
];
|
||||
|
||||
// --- Export state ---
|
||||
let exportSecrets = $state<SecretsMode>('exclude');
|
||||
let exporting = $state(false);
|
||||
let selectedCategories = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(categories.map(c => [c.key, true]))
|
||||
Object.fromEntries(allCategories.map(k => [k, true]))
|
||||
);
|
||||
|
||||
// --- Import state ---
|
||||
let importFile: File | null = $state(null);
|
||||
let importConflict = $state('skip');
|
||||
let importConflict = $state<ConflictMode>('skip');
|
||||
let importing = $state(false);
|
||||
let validating = $state(false);
|
||||
let validationResult: any = $state(null);
|
||||
@@ -48,7 +68,7 @@
|
||||
// --- Scheduled backup state ---
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
let scheduledSettings = $state({
|
||||
let scheduledSettings = $state<ScheduledSettings>({
|
||||
backup_scheduled_enabled: 'false',
|
||||
backup_scheduled_interval_hours: '24',
|
||||
backup_secrets_mode: 'exclude',
|
||||
@@ -57,18 +77,26 @@
|
||||
let savingSchedule = $state(false);
|
||||
|
||||
// --- Backup files ---
|
||||
let backupFiles = $state<any[]>([]);
|
||||
let backupFiles = $state<BackupFile[]>([]);
|
||||
let loadingFiles = $state(false);
|
||||
let confirmDeleteFile = $state('');
|
||||
let creatingBackup = $state(false);
|
||||
|
||||
// --- Pending restore state ---
|
||||
let pending = $state<PendingState | null>(null);
|
||||
let postRestoreModalOpen = $state(false);
|
||||
let restartingOverlay = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [settings, files] = await Promise.all([
|
||||
api('/backup/scheduled'),
|
||||
api('/backup/files'),
|
||||
const [settings, files, p] = await Promise.all([
|
||||
api<ScheduledSettings>('/backup/scheduled'),
|
||||
api<BackupFile[]>('/backup/files'),
|
||||
api<PendingState>('/backup/pending-restore'),
|
||||
]);
|
||||
scheduledSettings = settings;
|
||||
backupFiles = files;
|
||||
pending = p;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
@@ -77,8 +105,54 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function cancelPending(): Promise<void> {
|
||||
try {
|
||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||
snackSuccess(t('backup.pendingCancelled'));
|
||||
pending = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function applyAndRestart(): Promise<void> {
|
||||
try {
|
||||
await api('/backup/apply-restart', { method: 'POST' });
|
||||
restartingOverlay = true;
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
const poll = async (): Promise<void> => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const res = await fetch('/api/health');
|
||||
if (res.ok && Date.now() - startedAt > 2000) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch { /* still down */ }
|
||||
if (attempts < 120) setTimeout(poll, 1000);
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
} catch (err: any) {
|
||||
restartingOverlay = false;
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualBackup(): Promise<void> {
|
||||
creatingBackup = true;
|
||||
try {
|
||||
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||
snackSuccess(t('backup.manualCreated'));
|
||||
await refreshFiles();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
creatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
async function doExport() {
|
||||
async function doExport(): Promise<void> {
|
||||
if (exportSecrets === 'include') {
|
||||
confirmExportOpen = true;
|
||||
return;
|
||||
@@ -86,7 +160,7 @@
|
||||
await performExport();
|
||||
}
|
||||
|
||||
async function performExport() {
|
||||
async function performExport(): Promise<void> {
|
||||
confirmExportOpen = false;
|
||||
exporting = true;
|
||||
try {
|
||||
@@ -111,8 +185,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate ---
|
||||
async function validateFile() {
|
||||
// --- Validate / Import ---
|
||||
function handleFileSelect(file: File | null): void {
|
||||
importFile = file;
|
||||
validationResult = null;
|
||||
importResult = null;
|
||||
}
|
||||
|
||||
async function validateFile(): Promise<void> {
|
||||
if (!importFile) return;
|
||||
validating = true;
|
||||
validationResult = null;
|
||||
@@ -120,16 +200,7 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch('/api/backup/validate', {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||
validationResult = await res.json();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
@@ -138,12 +209,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Import ---
|
||||
async function doImport() {
|
||||
function doImport(): void {
|
||||
confirmImportOpen = true;
|
||||
}
|
||||
|
||||
async function performImport() {
|
||||
async function performImport(): Promise<void> {
|
||||
confirmImportOpen = false;
|
||||
if (!importFile) return;
|
||||
importing = true;
|
||||
@@ -151,18 +221,15 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
|
||||
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
importResult = await res.json();
|
||||
snackSuccess(t('backup.importSuccess'));
|
||||
pending = importResult;
|
||||
snackSuccess(t('backup.restorePrepared'));
|
||||
postRestoreModalOpen = true;
|
||||
importFile = null;
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -171,10 +238,10 @@
|
||||
}
|
||||
|
||||
// --- Scheduled settings ---
|
||||
async function saveSchedule() {
|
||||
async function saveSchedule(): Promise<void> {
|
||||
savingSchedule = true;
|
||||
try {
|
||||
scheduledSettings = await api('/backup/scheduled', {
|
||||
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(scheduledSettings),
|
||||
});
|
||||
@@ -187,10 +254,10 @@
|
||||
}
|
||||
|
||||
// --- File management ---
|
||||
async function refreshFiles() {
|
||||
async function refreshFiles(): Promise<void> {
|
||||
loadingFiles = true;
|
||||
try {
|
||||
backupFiles = await api('/backup/files');
|
||||
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -198,7 +265,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(filename: string) {
|
||||
async function downloadFile(filename: string): Promise<void> {
|
||||
try {
|
||||
const data = await api(`/backup/files/${filename}`);
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
@@ -213,7 +280,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(filename: string) {
|
||||
async function deleteFile(filename: string): Promise<void> {
|
||||
try {
|
||||
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
||||
snackSuccess(t('backup.fileDeleted'));
|
||||
@@ -223,318 +290,61 @@
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
importFile = input.files[0];
|
||||
validationResult = null;
|
||||
importResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
||||
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||
|
||||
function toggleAll() {
|
||||
const newVal = !allSelected;
|
||||
for (const key of Object.keys(selectedCategories)) {
|
||||
selectedCategories[key] = newVal;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('backup.title')} description={t('backup.description')} />
|
||||
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Export Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiDatabaseExport" size={18} />
|
||||
{t('backup.export')}
|
||||
</h3>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
||||
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-xs font-medium">{t('backup.categories')}</label>
|
||||
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
||||
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{#each categories as cat}
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
||||
{t(cat.label)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-page stagger-children">
|
||||
<div class="action-deck">
|
||||
<ExportPanel
|
||||
{selectedCategories}
|
||||
{exportSecrets}
|
||||
{exporting}
|
||||
onCategoriesChange={(next) => selectedCategories = next}
|
||||
onSecretsChange={(next) => exportSecrets = next}
|
||||
onExport={doExport}
|
||||
/>
|
||||
<ImportPanel
|
||||
{importFile}
|
||||
{importConflict}
|
||||
{validating}
|
||||
{validationResult}
|
||||
{importing}
|
||||
{importResult}
|
||||
onFileSelect={handleFileSelect}
|
||||
onConflictChange={(mode) => importConflict = mode}
|
||||
onValidate={validateFile}
|
||||
onImport={doImport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secrets mode -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</label>
|
||||
<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" />
|
||||
{t('backup.secretsExclude')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="masked" />
|
||||
{t('backup.secretsMasked')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="include" />
|
||||
{t('backup.secretsInclude')}
|
||||
</label>
|
||||
</div>
|
||||
{#if exportSecrets === 'include'}
|
||||
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlert" size={14} />
|
||||
{t('backup.secretsWarningExport')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ScheduleCassette
|
||||
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
|
||||
bind:secretsMode={scheduledSettings.backup_secrets_mode}
|
||||
bind:retentionCount={scheduledSettings.backup_retention_count}
|
||||
saving={savingSchedule}
|
||||
onToggle={() => scheduledSettings.backup_scheduled_enabled =
|
||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
|
||||
onSave={saveSchedule}
|
||||
/>
|
||||
|
||||
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
||||
{#if exporting}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
{/if}
|
||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- Import Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiDatabaseImport" size={18} />
|
||||
{t('backup.import')}
|
||||
</h3>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
||||
|
||||
<!-- File picker -->
|
||||
<div class="mb-4">
|
||||
<input type="file" accept=".json" onchange={handleFileSelect}
|
||||
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
||||
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
||||
</div>
|
||||
|
||||
{#if importFile}
|
||||
<!-- Validate -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
||||
{#if validating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
||||
{/if}
|
||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if validationResult}
|
||||
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||
<div class="flex items-center gap-2 mb-2 font-medium">
|
||||
{#if validationResult.valid}
|
||||
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
||||
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
||||
{:else}
|
||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
||||
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if Object.keys(validationResult.entity_counts || {}).length}
|
||||
<div class="mb-2">
|
||||
<span class="font-medium">{t('backup.entities')}:</span>
|
||||
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
||||
<span class="inline-block mr-2">{cat}: {count}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#each validationResult.warnings || [] as w}
|
||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
||||
<MdiIcon name="mdiAlert" size={12} />
|
||||
<span>{w}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#each validationResult.errors || [] as e}
|
||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={12} />
|
||||
<span>{e}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conflict mode -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</label>
|
||||
<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" />
|
||||
{t('backup.conflictSkip')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="rename" />
|
||||
{t('backup.conflictRename')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="overwrite" />
|
||||
{t('backup.conflictOverwrite')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={doImport}
|
||||
disabled={importing || !validationResult?.valid}>
|
||||
{#if importing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiUpload" size={14} />
|
||||
{/if}
|
||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||
</Button>
|
||||
|
||||
{#if importResult}
|
||||
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
||||
<div class="space-y-0.5">
|
||||
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
||||
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
||||
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
||||
{#if importResult.errors?.length}
|
||||
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
||||
{#each importResult.errors as e}
|
||||
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importResult.warnings?.length}
|
||||
{#each importResult.warnings as w}
|
||||
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Scheduled Backups Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiClockOutline" size={18} />
|
||||
{t('backup.scheduled')}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox"
|
||||
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
||||
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
||||
</label>
|
||||
|
||||
{#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}
|
||||
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>
|
||||
<option value="24">24 {t('backup.hours')}</option>
|
||||
<option value="48">48 {t('backup.hours')}</option>
|
||||
<option value="72">72 {t('backup.hours')}</option>
|
||||
<option value="168">168 {t('backup.hours')} (7d)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
||||
<select 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>
|
||||
<option value="include">{t('backup.secretsInclude')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
||||
<select 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>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
||||
{savingSchedule ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Saved Backup Files -->
|
||||
<Card>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
<MdiIcon name="mdiFolder" size={18} />
|
||||
{t('backup.savedFiles')}
|
||||
</h3>
|
||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backupFiles.length === 0}
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each backupFiles as file}
|
||||
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
||||
style="border-color: var(--color-border);">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name="mdiFileDocument" size={14} />
|
||||
<span class="font-mono">{file.filename}</span>
|
||||
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => downloadFile(file.filename)}
|
||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
</button>
|
||||
<button onclick={() => confirmDeleteFile = file.filename}
|
||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
||||
style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiDelete" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
<BackupLedger
|
||||
files={backupFiles}
|
||||
loading={loadingFiles}
|
||||
creating={creatingBackup}
|
||||
onCreate={createManualBackup}
|
||||
onRefresh={refreshFiles}
|
||||
onDownload={downloadFile}
|
||||
onDelete={(filename) => confirmDeleteFile = filename}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -568,3 +378,200 @@
|
||||
onconfirm={() => deleteFile(confirmDeleteFile)}
|
||||
oncancel={() => confirmDeleteFile = ''}
|
||||
/>
|
||||
|
||||
<!-- Post-restore modal: Apply now or later -->
|
||||
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||
{#if postRestoreModalOpen && pending?.pending}
|
||||
<div class="post-restore-backdrop"
|
||||
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"
|
||||
class="post-restore-card"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<div class="post-restore-head">
|
||||
<div class="post-restore-icon">
|
||||
<MdiIcon name="mdiClockAlert" size={22} />
|
||||
</div>
|
||||
<div class="post-restore-text">
|
||||
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
|
||||
<p>{t('backup.restoreApplyPrompt')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-restore-actions">
|
||||
<button class="post-restore-later" type="button"
|
||||
onclick={() => postRestoreModalOpen = false}>
|
||||
{t('backup.applyLater')}
|
||||
</button>
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Restarting overlay -->
|
||||
{#if restartingOverlay}
|
||||
<div class="restart-overlay" role="alert" aria-live="assertive">
|
||||
<div class="restart-card">
|
||||
<div class="restart-spinner">
|
||||
<MdiIcon name="mdiRestart" size={40} />
|
||||
</div>
|
||||
<p class="restart-title">{t('backup.restartingTitle')}</p>
|
||||
<p class="restart-sub">{t('backup.restartingDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backup-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.action-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.action-deck { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* Post-restore modal */
|
||||
.post-restore-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.post-restore-card {
|
||||
background: var(--color-glass-elev);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 22px;
|
||||
padding: 1.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.post-restore-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
.post-restore-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-fg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.post-restore-text { min-width: 0; }
|
||||
.post-restore-text h3 {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.post-restore-text p {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.post-restore-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.post-restore-later {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.post-restore-later:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
/* Restarting overlay */
|
||||
.restart-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.restart-card {
|
||||
text-align: center;
|
||||
padding: 1.6rem 2rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.restart-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 0.85rem;
|
||||
color: var(--color-primary);
|
||||
animation: restart-spin 1.2s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.restart-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.restart-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
@keyframes restart-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.restart-spinner { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduledSettings {
|
||||
backup_scheduled_enabled: string;
|
||||
backup_scheduled_interval_hours: string;
|
||||
backup_secrets_mode: string;
|
||||
backup_retention_count: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: BackupFile[];
|
||||
scheduled: ScheduledSettings;
|
||||
pending: { pending: boolean } | null;
|
||||
}
|
||||
|
||||
let { files, scheduled, pending }: Props = $props();
|
||||
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function latestCreatedAt(list: BackupFile[]): string | null {
|
||||
const stamps = list
|
||||
.map(f => f.created_at)
|
||||
.filter((s): s is string => !!s)
|
||||
.sort();
|
||||
return stamps.length ? stamps[stamps.length - 1] : null;
|
||||
}
|
||||
|
||||
function ageHours(iso: string | null): number {
|
||||
if (!iso) return Infinity;
|
||||
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
if (isNaN(date.getTime())) return Infinity;
|
||||
return (Date.now() - date.getTime()) / 3_600_000;
|
||||
}
|
||||
|
||||
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
|
||||
const out: Array<{ label: string; tone?: Tone }> = [];
|
||||
if (pending?.pending) {
|
||||
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
|
||||
}
|
||||
if (scheduled.backup_scheduled_enabled === 'true') {
|
||||
out.push({
|
||||
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
|
||||
tone: 'mint',
|
||||
});
|
||||
} else {
|
||||
out.push({ label: t('backup.scheduleOff') });
|
||||
}
|
||||
const latest = latestCreatedAt(files);
|
||||
if (latest) {
|
||||
const hours = ageHours(latest);
|
||||
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
|
||||
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
|
||||
} else {
|
||||
out.push({ label: t('backup.never'), tone: 'citrus' });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('backup.title')}
|
||||
emphasis={t('backup.titleEmphasis')}
|
||||
description={t('backup.description')}
|
||||
crumb={t('crumbs.systemMaintenance')}
|
||||
count={files.length}
|
||||
countLabel={t('backup.countLabel')}
|
||||
{pills}
|
||||
/>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||
|
||||
interface Props {
|
||||
files: BackupFile[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreate: () => void;
|
||||
onRefresh: () => void;
|
||||
onDownload: (filename: string) => void;
|
||||
onDelete: (filename: string) => void;
|
||||
}
|
||||
|
||||
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function parseDate(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function absoluteTime(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
return date ? date.toLocaleString() : '—';
|
||||
}
|
||||
|
||||
function ageTone(iso: string | null | undefined): Tone {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return 'coral';
|
||||
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||
if (hours < 48) return 'mint';
|
||||
if (hours < 24 * 7) return 'sky';
|
||||
if (hours < 24 * 30) return 'citrus';
|
||||
return 'coral';
|
||||
}
|
||||
|
||||
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
|
||||
</script>
|
||||
|
||||
<section class="ledger glass">
|
||||
<header class="ledger-head">
|
||||
<div>
|
||||
<div class="ledger-eyebrow">
|
||||
<MdiIcon name="mdiArchiveOutline" size={12} />
|
||||
<span>{t('backup.savedFiles')}</span>
|
||||
</div>
|
||||
{#if files.length > 0}
|
||||
<div class="ledger-summary">
|
||||
<span class="ledger-count font-mono">{files.length}</span>
|
||||
<span class="ledger-count-label">{t('backup.countLabel')}</span>
|
||||
<span class="ledger-sep">·</span>
|
||||
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ledger-actions">
|
||||
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
|
||||
{#if creating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
{/if}
|
||||
{creating ? t('common.loading') : t('backup.createManual')}
|
||||
</Button>
|
||||
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
|
||||
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
|
||||
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if files.length === 0}
|
||||
<div class="ledger-empty">
|
||||
<MdiIcon name="mdiCloudOffOutline" size={28} />
|
||||
<p>{t('backup.noFiles')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="ledger-list">
|
||||
{#each files as file (file.filename)}
|
||||
{@const tone = ageTone(file.created_at)}
|
||||
<li class="row" data-tone={tone}>
|
||||
<span class="row-edge" aria-hidden="true"></span>
|
||||
<span class="row-dot" aria-hidden="true"></span>
|
||||
<div class="row-time">
|
||||
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
|
||||
<span class="row-abs" title={absoluteTime(file.created_at)}>
|
||||
{absoluteTime(file.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-name">
|
||||
<span class="row-filename" title={file.filename}>{file.filename}</span>
|
||||
</div>
|
||||
<span class="row-size font-mono">{formatBytes(file.size)}</span>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" type="button"
|
||||
onclick={() => onDownload(file.filename)}
|
||||
aria-label={t('backup.download')}
|
||||
title={t('backup.download')}>
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" type="button"
|
||||
onclick={() => onDelete(file.filename)}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}>
|
||||
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.ledger {
|
||||
padding: 1.4rem 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
.ledger-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.ledger-count {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--color-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger-count-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||
.ledger-total {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.icon-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.icon-btn-danger:hover:not(:disabled) {
|
||||
color: var(--color-error-fg);
|
||||
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.spinning {
|
||||
display: inline-flex;
|
||||
animation: ledger-spin 1.1s linear infinite;
|
||||
}
|
||||
@keyframes ledger-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ledger-empty {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.6rem 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
.ledger-empty p { margin: 0; font-size: 0.8rem; }
|
||||
|
||||
.ledger-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.row-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||
.row-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
|
||||
|
||||
.row-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
.row-rel {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.row-abs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 14rem;
|
||||
}
|
||||
.row-name {
|
||||
min-width: 0;
|
||||
}
|
||||
.row-filename {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row:hover .row-filename { color: var(--color-foreground); }
|
||||
|
||||
.row-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
.row:hover .row-actions,
|
||||
.row:focus-within .row-actions { opacity: 1; }
|
||||
@media (max-width: 640px) {
|
||||
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
|
||||
.row-time { grid-column: 2; min-width: 0; }
|
||||
.row-name { grid-column: 1 / -1; }
|
||||
.row-size { grid-column: 3; grid-row: 1; }
|
||||
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row { transition: none !important; }
|
||||
.row:hover { transform: none !important; }
|
||||
.spinning { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||
|
||||
interface Props {
|
||||
selectedCategories: Record<string, boolean>;
|
||||
exportSecrets: SecretsMode;
|
||||
exporting: boolean;
|
||||
onCategoriesChange: (next: Record<string, boolean>) => void;
|
||||
onSecretsChange: (next: SecretsMode) => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedCategories,
|
||||
exportSecrets,
|
||||
exporting,
|
||||
onCategoriesChange,
|
||||
onSecretsChange,
|
||||
onExport,
|
||||
}: Props = $props();
|
||||
|
||||
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
|
||||
{
|
||||
key: 'identity',
|
||||
labelKey: 'backup.catGroupIdentity',
|
||||
icon: 'mdiAccountNetwork',
|
||||
cats: [
|
||||
{ key: 'providers', labelKey: 'backup.catProviders' },
|
||||
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
|
||||
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
|
||||
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
|
||||
{ key: 'targets', labelKey: 'backup.catTargets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notif',
|
||||
labelKey: 'backup.catGroupNotif',
|
||||
icon: 'mdiBellOutline',
|
||||
cats: [
|
||||
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
|
||||
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
|
||||
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cmd',
|
||||
labelKey: 'backup.catGroupCmd',
|
||||
icon: 'mdiConsoleLine',
|
||||
cats: [
|
||||
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
|
||||
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
|
||||
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
labelKey: 'backup.catGroupSystem',
|
||||
icon: 'mdiCog',
|
||||
cats: [
|
||||
{ key: 'actions', labelKey: 'backup.catActions' },
|
||||
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function toggleCat(key: string): void {
|
||||
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
|
||||
}
|
||||
|
||||
function groupState(groupKey: string): 'all' | 'none' | 'some' {
|
||||
const group = categoryGroups.find(g => g.key === groupKey);
|
||||
if (!group) return 'none';
|
||||
const flags = group.cats.map(c => !!selectedCategories[c.key]);
|
||||
if (flags.every(v => v)) return 'all';
|
||||
if (flags.every(v => !v)) return 'none';
|
||||
return 'some';
|
||||
}
|
||||
|
||||
function toggleGroup(groupKey: string): void {
|
||||
const group = categoryGroups.find(g => g.key === groupKey);
|
||||
if (!group) return;
|
||||
const target = groupState(groupKey) !== 'all';
|
||||
const next = { ...selectedCategories };
|
||||
for (const c of group.cats) next[c.key] = target;
|
||||
onCategoriesChange(next);
|
||||
}
|
||||
|
||||
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
|
||||
|
||||
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
|
||||
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
|
||||
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
|
||||
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="export-panel glass">
|
||||
<header class="panel-head">
|
||||
<div class="panel-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseExport" size={14} />
|
||||
<span>{t('backup.export')}</span>
|
||||
</div>
|
||||
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Step 1: categories -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">01</span>
|
||||
<span class="step-label">{t('backup.stepCategories')}</span>
|
||||
<span class="step-count">{totalSelected}</span>
|
||||
</div>
|
||||
<div class="group-grid">
|
||||
{#each categoryGroups as group}
|
||||
{@const state = groupState(group.key)}
|
||||
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
|
||||
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
|
||||
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
|
||||
<span class="group-title">{t(group.labelKey)}</span>
|
||||
<span class="group-state">
|
||||
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
|
||||
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
|
||||
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
|
||||
</span>
|
||||
</button>
|
||||
<div class="chip-row">
|
||||
{#each group.cats as cat}
|
||||
<button class="chip" type="button"
|
||||
class:chip-on={selectedCategories[cat.key]}
|
||||
onclick={() => toggleCat(cat.key)}>
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: secrets -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">02</span>
|
||||
<span class="step-label">{t('backup.stepSecrets')}</span>
|
||||
</div>
|
||||
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
|
||||
{#each secretsModes as mode}
|
||||
<button type="button"
|
||||
role="radio"
|
||||
aria-checked={exportSecrets === mode.value}
|
||||
class="seg"
|
||||
class:seg-on={exportSecrets === mode.value}
|
||||
onclick={() => onSecretsChange(mode.value)}>
|
||||
<MdiIcon name={mode.icon} size={14} />
|
||||
<span>{t(mode.labelKey)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if exportSecrets === 'include'}
|
||||
<div class="warn-strip" role="status">
|
||||
<span class="warn-edge" aria-hidden="true"></span>
|
||||
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
|
||||
<span>{t('backup.secretsWarningExport')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 3: CTA -->
|
||||
<div class="step step-cta">
|
||||
<Button onclick={onExport} disabled={exporting || noneSelected}>
|
||||
{#if exporting}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
{/if}
|
||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.export-panel {
|
||||
padding: 1.5rem 1.5rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.panel-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.panel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.step-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.step-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.step-count {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (min-width: 560px) {
|
||||
.group-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.group {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
padding: 0.55rem 0.65rem 0.7rem;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
|
||||
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.15rem 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground);
|
||||
font-family: inherit;
|
||||
}
|
||||
.group-icon { color: var(--color-primary); display: inline-flex; }
|
||||
.group-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.group-state {
|
||||
display: inline-flex;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.group-all .group-state { color: var(--color-primary); }
|
||||
.group-some .group-state { color: var(--color-citrus); }
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
|
||||
.chip-on {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||
color: var(--color-foreground);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
line-height: 1.25;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||
}
|
||||
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
.seg-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-foreground);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--color-highlight),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.warn-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-error-fg);
|
||||
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
|
||||
overflow: hidden;
|
||||
}
|
||||
.warn-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--color-coral);
|
||||
}
|
||||
|
||||
.step-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,603 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
entity_counts?: Record<string, number>;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
created?: number;
|
||||
skipped?: number;
|
||||
overwritten?: number;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
importFile: File | null;
|
||||
importConflict: ConflictMode;
|
||||
validating: boolean;
|
||||
validationResult: ValidationResult | null;
|
||||
importing: boolean;
|
||||
importResult: ImportResult | null;
|
||||
onFileSelect: (file: File | null) => void;
|
||||
onConflictChange: (mode: ConflictMode) => void;
|
||||
onValidate: () => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
importFile,
|
||||
importConflict,
|
||||
validating,
|
||||
validationResult,
|
||||
importing,
|
||||
importResult,
|
||||
onFileSelect,
|
||||
onConflictChange,
|
||||
onValidate,
|
||||
onImport,
|
||||
}: Props = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let inputEl = $state<HTMLInputElement | undefined>();
|
||||
|
||||
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
|
||||
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
|
||||
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
|
||||
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
|
||||
];
|
||||
|
||||
function pickFile(): void {
|
||||
inputEl?.click();
|
||||
}
|
||||
|
||||
function handleInput(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
onFileSelect(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(): void {
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const entityCount = $derived(
|
||||
validationResult?.entity_counts
|
||||
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="import-panel glass">
|
||||
<header class="panel-head">
|
||||
<div class="panel-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseImport" size={14} />
|
||||
<span>{t('backup.import')}</span>
|
||||
</div>
|
||||
<h3 class="panel-title">{t('backup.importDescription')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Step 1: file -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">01</span>
|
||||
<span class="step-label">{t('backup.stepFile')}</span>
|
||||
</div>
|
||||
|
||||
{#if importFile}
|
||||
<div class="file-pill">
|
||||
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
|
||||
<div class="file-meta">
|
||||
<div class="file-name" title={importFile.name}>{importFile.name}</div>
|
||||
<div class="file-size">{formatBytes(importFile.size)}</div>
|
||||
</div>
|
||||
<button class="file-change" type="button" onclick={pickFile}>
|
||||
<MdiIcon name="mdiSwapHorizontal" size={14} />
|
||||
<span>{t('backup.changeFile')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button"
|
||||
class="dropzone"
|
||||
class:dropzone-active={dragging}
|
||||
onclick={pickFile}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}>
|
||||
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
|
||||
<span class="dropzone-text">
|
||||
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
<input bind:this={inputEl} type="file" accept=".json,application/json"
|
||||
class="visually-hidden" onchange={handleInput} />
|
||||
</div>
|
||||
|
||||
<!-- Step 2: validate -->
|
||||
{#if importFile}
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">02</span>
|
||||
<span class="step-label">{t('backup.stepValidate')}</span>
|
||||
{#if validationResult}
|
||||
<span class="validate-pill"
|
||||
class:validate-ok={validationResult.valid}
|
||||
class:validate-bad={!validationResult.valid}>
|
||||
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
|
||||
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !validationResult}
|
||||
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
|
||||
{#if validating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
|
||||
{/if}
|
||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
|
||||
{#if entityCount > 0}
|
||||
<div class="validate-summary">
|
||||
<span class="validate-count font-mono">{entityCount}</span>
|
||||
<span class="validate-count-label">{t('backup.entities')}</span>
|
||||
</div>
|
||||
<div class="validate-categories">
|
||||
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
|
||||
<span class="validate-cat">
|
||||
<span class="validate-cat-num font-mono">{count}</span>
|
||||
<span class="validate-cat-name">{cat}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if validationResult.warnings?.length}
|
||||
<ul class="validate-list validate-warn">
|
||||
{#each validationResult.warnings as w}
|
||||
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if validationResult.errors?.length}
|
||||
<ul class="validate-list validate-err">
|
||||
{#each validationResult.errors as e}
|
||||
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: conflict mode -->
|
||||
{#if importFile && validationResult?.valid}
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">03</span>
|
||||
<span class="step-label">{t('backup.stepConflict')}</span>
|
||||
</div>
|
||||
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
|
||||
{#each conflictOptions as opt}
|
||||
<button type="button"
|
||||
role="radio"
|
||||
aria-checked={importConflict === opt.value}
|
||||
class="seg"
|
||||
class:seg-on={importConflict === opt.value}
|
||||
onclick={() => onConflictChange(opt.value)}>
|
||||
<MdiIcon name={opt.icon} size={14} />
|
||||
<span>{t(opt.labelKey)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: CTA + results -->
|
||||
<div class="step step-cta">
|
||||
{#if importFile && !validationResult?.valid && !validating}
|
||||
<div class="cta-hint">
|
||||
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||
<span>{t('backup.validateFirst')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
|
||||
{#if importing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiUpload" size={14} />
|
||||
{/if}
|
||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||
</Button>
|
||||
|
||||
{#if importResult}
|
||||
<div class="import-results">
|
||||
<div class="result-tiles">
|
||||
<div class="result-tile tile-created">
|
||||
<span class="result-num font-mono">{importResult.created ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultCreated')}</span>
|
||||
</div>
|
||||
<div class="result-tile tile-skipped">
|
||||
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultSkipped')}</span>
|
||||
</div>
|
||||
<div class="result-tile tile-overwritten">
|
||||
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultOverwritten')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if importResult.errors?.length}
|
||||
<ul class="validate-list validate-err">
|
||||
{#each importResult.errors as e}
|
||||
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if importResult.warnings?.length}
|
||||
<ul class="validate-list validate-warn">
|
||||
{#each importResult.warnings as w}
|
||||
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.import-panel {
|
||||
padding: 1.5rem 1.5rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.panel-head { position: relative; z-index: 1; }
|
||||
.panel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||
.step-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.step-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Drop zone */
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
padding: 1.65rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
border: 1.5px dashed var(--color-rule-strong);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
|
||||
min-height: 140px;
|
||||
}
|
||||
.dropzone:hover {
|
||||
color: var(--color-foreground);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.dropzone-active {
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
|
||||
transform: scale(1.005);
|
||||
}
|
||||
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
|
||||
.dropzone-text { line-height: 1.4; max-width: 28ch; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* File pill */
|
||||
.file-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.file-icon { color: var(--color-primary); flex-shrink: 0; }
|
||||
.file-meta { flex: 1; min-width: 0; }
|
||||
.file-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.file-size {
|
||||
font-size: 0.66rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.file-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.32rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
|
||||
/* Validation */
|
||||
.validate-pill {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.validate-ok {
|
||||
color: var(--color-success-fg);
|
||||
background: var(--color-success-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
|
||||
}
|
||||
.validate-bad {
|
||||
color: var(--color-error-fg);
|
||||
background: var(--color-error-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
||||
}
|
||||
|
||||
.validate-card {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.validate-card-bad {
|
||||
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
|
||||
}
|
||||
.validate-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.validate-count {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1;
|
||||
}
|
||||
.validate-count-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.validate-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.validate-cat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.validate-cat-num {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.validate-cat-name {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.validate-list {
|
||||
list-style: none;
|
||||
padding: 0; margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.validate-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.validate-warn li { color: var(--color-warning-fg); }
|
||||
.validate-err li { color: var(--color-error-fg); }
|
||||
|
||||
/* Segmented (same vocabulary as ExportPanel) */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
line-height: 1.25;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||
}
|
||||
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
.seg-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-foreground);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--color-highlight),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.cta-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.import-results {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.result-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.result-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.result-num {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.result-label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||
.tile-created .result-num { color: var(--color-mint); }
|
||||
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
|
||||
.tile-skipped .result-num { color: var(--color-sky); }
|
||||
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||
.tile-overwritten .result-num { color: var(--color-citrus); }
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface PendingState {
|
||||
pending: boolean;
|
||||
uploaded_at?: string | null;
|
||||
uploaded_by?: string | null;
|
||||
conflict_mode?: string;
|
||||
supervised?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pending: PendingState | null;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { pending, onApply, onCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pending?.pending}
|
||||
<div class="pending-strip animate-rise" role="alert">
|
||||
<span class="pending-edge" aria-hidden="true"></span>
|
||||
<span class="aurora-pulse error" aria-hidden="true"></span>
|
||||
<div class="pending-body">
|
||||
<div class="pending-title">
|
||||
<MdiIcon name="mdiShieldAlertOutline" size={16} />
|
||||
<span>{t('backup.pendingTitle')}</span>
|
||||
</div>
|
||||
<div class="pending-meta">
|
||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
|
||||
<span class="pending-dot">·</span>
|
||||
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pending-actions">
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={onApply}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
<button class="pending-cancel" onclick={onCancel} type="button">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pending-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: 18px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||
box-shadow:
|
||||
var(--shadow-card),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pending-strip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
||||
opacity: 0.35;
|
||||
}
|
||||
.pending-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
|
||||
}
|
||||
.pending-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.pending-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pending-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.18rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pending-dot {
|
||||
opacity: 0.6;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
.pending-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pending-cancel {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pending-cancel:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
intervalHours: string;
|
||||
secretsMode: string;
|
||||
retentionCount: string;
|
||||
saving: boolean;
|
||||
onToggle: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
enabled,
|
||||
intervalHours = $bindable(),
|
||||
secretsMode = $bindable(),
|
||||
retentionCount = $bindable(),
|
||||
saving,
|
||||
onToggle,
|
||||
onSave,
|
||||
}: Props = $props();
|
||||
|
||||
const intervalItems: GridItem[] = $derived([
|
||||
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
|
||||
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
|
||||
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
|
||||
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
|
||||
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
|
||||
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
|
||||
]);
|
||||
|
||||
const secretsItems: GridItem[] = $derived([
|
||||
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
|
||||
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
|
||||
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
|
||||
]);
|
||||
|
||||
const retentionItems: GridItem[] = $derived([
|
||||
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
|
||||
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
|
||||
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
|
||||
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section class="cassette glass" class:cassette-on={enabled}>
|
||||
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
|
||||
<span class="toggle-track" class:toggle-on={enabled}>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">
|
||||
<span class="cassette-eyebrow">
|
||||
<MdiIcon name="mdiClockOutline" size={12} />
|
||||
<span>{t('backup.scheduled')}</span>
|
||||
</span>
|
||||
<span class="cassette-title">{t('backup.enableScheduled')}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if enabled}
|
||||
<div class="cassette-controls">
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.interval')}</span>
|
||||
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.secretsMode')}</span>
|
||||
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.retention')}</span>
|
||||
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cassette-off">{t('backup.scheduleOff')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="cassette-save">
|
||||
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
|
||||
<MdiIcon name="mdiContentSave" size={14} />
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.cassette {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1.1rem;
|
||||
padding: 0.95rem 1.15rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
|
||||
|
||||
.cassette-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
padding: 0.2rem 0.1rem;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||
}
|
||||
.toggle-on .toggle-thumb {
|
||||
background: white;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.cassette-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.cassette-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.cassette-controls {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.7rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||
.ctl-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.cassette-off {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-style: italic;
|
||||
font-family: var(--font-display);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cassette-save {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.cassette-save { width: 100%; }
|
||||
.cassette-save > :global(*) { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
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';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import { chatActionItems } from '$lib/grid-items';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
@@ -20,6 +24,7 @@
|
||||
|
||||
import TargetForm from './TargetForm.svelte';
|
||||
import ReceiverSection from './ReceiverSection.svelte';
|
||||
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -90,6 +95,53 @@
|
||||
label: tt.charAt(0).toUpperCase() + tt.slice(1),
|
||||
})));
|
||||
|
||||
function targetTiles(target: NotificationTarget): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// from multiple types appear side-by-side. The receivers count is
|
||||
// already shown inside the `target-summary` button, so we don't repeat
|
||||
// it as a tile.
|
||||
tiles.push({
|
||||
icon: TYPE_ICONS[target.type] || 'mdiTarget',
|
||||
label: target.type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const botName = getBotName(target);
|
||||
if (botName) {
|
||||
tiles.push({
|
||||
icon: 'mdiRobot',
|
||||
label: botName,
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram · @bot · Family chat" without expanding.
|
||||
const cfg = (target.config || {}) as Record<string, any>;
|
||||
if (target.type === 'telegram' && cfg.chat_id) {
|
||||
tiles.push({
|
||||
icon: 'mdiChat',
|
||||
label: String(cfg.chat_id),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook target — show host
|
||||
if (target.type === 'webhook' && cfg.url) {
|
||||
let host = String(cfg.url);
|
||||
try { host = new URL(host).host; } catch { /* keep raw */ }
|
||||
tiles.push({
|
||||
icon: 'mdiLinkVariant',
|
||||
label: host,
|
||||
hint: String(cfg.url),
|
||||
href: String(cfg.url),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
@@ -114,7 +166,7 @@
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', bot_id: 0, bot_token: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||
// Discord/Slack shared settings
|
||||
username: '',
|
||||
// ntfy shared settings
|
||||
@@ -127,13 +179,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();
|
||||
@@ -150,6 +214,20 @@
|
||||
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
||||
let receiverTesting = $state<Record<number, boolean>>({});
|
||||
|
||||
// Per-target expansion state for the receivers section. Hidden by default.
|
||||
let expandedTargets = $state<Set<number>>(new SvelteSet());
|
||||
|
||||
function isExpanded(id: number): boolean {
|
||||
return expandedTargets.has(id);
|
||||
}
|
||||
function toggleExpanded(id: number) {
|
||||
if (expandedTargets.has(id)) expandedTargets.delete(id);
|
||||
else expandedTargets.add(id);
|
||||
}
|
||||
function expandTarget(id: number) {
|
||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||
}
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
@@ -164,6 +242,112 @@
|
||||
// ── Data loading ──
|
||||
|
||||
onMount(load);
|
||||
|
||||
// ── Bot grouping ──
|
||||
|
||||
type TargetGroup = {
|
||||
key: string;
|
||||
type: string;
|
||||
name: string;
|
||||
subtitle: string | null;
|
||||
icon: string;
|
||||
typeBadge: string | null;
|
||||
botHref: string | null;
|
||||
botEntityId: number | null;
|
||||
muted: boolean;
|
||||
targets: NotificationTarget[];
|
||||
};
|
||||
|
||||
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
|
||||
|
||||
const groupedTargets = $derived.by<TargetGroup[]>(() => {
|
||||
const groups = new Map<string, TargetGroup>();
|
||||
for (const tgt of targets) {
|
||||
const isBotType = BOT_TYPES.has(tgt.type);
|
||||
const botId = isBotType ? getBotEntityId(tgt) : null;
|
||||
const key = isBotType
|
||||
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
|
||||
: `${tgt.type}:direct`;
|
||||
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
|
||||
let name = '';
|
||||
let subtitle: string | null = null;
|
||||
let muted = false;
|
||||
|
||||
if (isBotType && botId) {
|
||||
if (tgt.type === 'telegram') {
|
||||
const bot = telegramBots.find(b => b.id === botId);
|
||||
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
|
||||
icon = bot?.icon || 'mdiSend';
|
||||
} else if (tgt.type === 'email') {
|
||||
const bot = emailBots.find(b => b.id === botId);
|
||||
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||
subtitle = bot?.email || null;
|
||||
icon = bot?.icon || 'mdiEmailOutline';
|
||||
} else if (tgt.type === 'matrix') {
|
||||
const bot = matrixBots.find(b => b.id === botId);
|
||||
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||
subtitle = bot?.display_name || bot?.homeserver_url || null;
|
||||
icon = bot?.icon || 'mdiMatrix';
|
||||
}
|
||||
} else if (isBotType) {
|
||||
name = t('targets.groupNoBot');
|
||||
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||
muted = true;
|
||||
} else {
|
||||
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||
subtitle = t('targets.groupDirect');
|
||||
muted = true;
|
||||
}
|
||||
|
||||
group = {
|
||||
key,
|
||||
type: tgt.type,
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
typeBadge,
|
||||
botHref: isBotType && botId ? getBotHref(tgt) : null,
|
||||
botEntityId: isBotType ? botId : null,
|
||||
muted,
|
||||
targets: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.targets.push(tgt);
|
||||
}
|
||||
|
||||
const rank = (g: TargetGroup) => {
|
||||
if (g.type === 'broadcast') return 4;
|
||||
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
|
||||
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
|
||||
return 1; // bot-linked
|
||||
};
|
||||
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const ra = rank(a), rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
@@ -188,11 +372,26 @@
|
||||
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
||||
}
|
||||
|
||||
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
|
||||
async function discoverReceiverBotChats(botId: number) {
|
||||
if (!botId) return;
|
||||
try {
|
||||
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
|
||||
receiverBotChats = { ...receiverBotChats, [botId]: data };
|
||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||
}
|
||||
|
||||
// ── Target CRUD ──
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
formType = activeType || 'telegram';
|
||||
// Auto-select first available bot of the chosen type
|
||||
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;
|
||||
@@ -209,7 +408,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
|
||||
@@ -222,6 +421,7 @@
|
||||
// broadcast
|
||||
child_target_ids: c.child_target_ids || [],
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = tgt.id;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
@@ -248,7 +448,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 };
|
||||
@@ -264,10 +464,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;
|
||||
@@ -289,12 +491,15 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function remove(id: number) {
|
||||
try {
|
||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
}
|
||||
@@ -302,15 +507,27 @@
|
||||
|
||||
// ── Receiver CRUD ──
|
||||
|
||||
function openReceiverForm(targetId: number, targetType: string) {
|
||||
async function openReceiverForm(targetId: number, targetType: string) {
|
||||
// Force a remount of any picker palette when the same target is reopened
|
||||
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
|
||||
if (addingReceiverForTarget === targetId) {
|
||||
addingReceiverForTarget = null;
|
||||
await tick();
|
||||
}
|
||||
addingReceiverForTarget = targetId;
|
||||
expandTarget(targetId);
|
||||
receiverHeadersError = '';
|
||||
if (targetType === 'telegram') {
|
||||
receiverForm = { chat_id: '' };
|
||||
// Load bot chats for the target's bot
|
||||
// Show what we have immediately (cached list), then actively discover in the
|
||||
// background so any newly-added chats appear in the palette as soon as
|
||||
// Telegram returns them.
|
||||
const tgt = allTargets.find(t => t.id === targetId);
|
||||
const botId = tgt?.config?.bot_id;
|
||||
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
|
||||
if (botId) {
|
||||
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
|
||||
discoverReceiverBotChats(botId);
|
||||
}
|
||||
} else if (targetType === 'email') {
|
||||
receiverForm = { email: '' };
|
||||
} else if (targetType === 'webhook') {
|
||||
@@ -410,11 +627,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}
|
||||
@@ -444,6 +668,7 @@
|
||||
bind:showTelegramSettings
|
||||
onsave={save}
|
||||
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
|
||||
onnameinput={() => nameManuallyEdited = true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -463,53 +688,85 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each targets as target (target.id)}
|
||||
<Card hover entityId={target.id}>
|
||||
<!-- Target header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||
<p class="font-medium">{target.name}</p>
|
||||
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
||||
{#if target.type === 'broadcast' && target.child_targets?.length}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
||||
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
|
||||
{/if}
|
||||
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receivers list -->
|
||||
<ReceiverSection
|
||||
{target}
|
||||
typeIcons={TYPE_ICONS}
|
||||
{addingReceiverForTarget}
|
||||
bind:receiverForm
|
||||
{receiverSubmitting}
|
||||
{receiverHeadersError}
|
||||
{receiverBotChats}
|
||||
{receiverTesting}
|
||||
{receiverLabel}
|
||||
onopenReceiverForm={openReceiverForm}
|
||||
onsaveReceiver={saveReceiver}
|
||||
oncancelReceiver={() => addingReceiverForTarget = null}
|
||||
ontoggleReceiver={toggleReceiver}
|
||||
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
|
||||
ontestReceiver={testReceiver}
|
||||
onloadBotChats={loadReceiverBotChats}
|
||||
onchangeReceiverForm={(f) => receiverForm = f}
|
||||
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||
<div class="targets-list">
|
||||
{#each groupedTargets as group (group.key)}
|
||||
<section class="target-group">
|
||||
<BotGroupHeader
|
||||
icon={group.icon}
|
||||
name={group.name}
|
||||
subtitle={group.subtitle}
|
||||
targetCount={group.targets.length}
|
||||
typeBadge={!activeType ? group.typeBadge : null}
|
||||
botHref={group.botHref}
|
||||
botEntityId={group.botEntityId}
|
||||
muted={group.muted}
|
||||
/>
|
||||
</Card>
|
||||
<div class="target-group__items stagger-children">
|
||||
{#each group.targets as target (target.id)}
|
||||
{@const expanded = isExpanded(target.id)}
|
||||
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
|
||||
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
|
||||
<Card hover entityId={target.id}>
|
||||
<!-- Target header (clickable to toggle receiver visibility) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="target-summary"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`target-body-${target.id}`}
|
||||
onclick={() => toggleExpanded(target.id)}
|
||||
>
|
||||
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
|
||||
<MdiIcon name="mdiChevronRight" size={16} />
|
||||
</span>
|
||||
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||
<span class="target-summary__name">{target.name}</span>
|
||||
{#if childCount > 0}
|
||||
<span class="target-summary__count">
|
||||
<span class="target-summary__count-num">{childCount}</span>
|
||||
<span class="target-summary__count-label">{childLabel}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<MetaStrip tiles={targetTiles(target)} />
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receivers list (collapsible) -->
|
||||
{#if expanded}
|
||||
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
|
||||
<ReceiverSection
|
||||
{target}
|
||||
typeIcons={TYPE_ICONS}
|
||||
{addingReceiverForTarget}
|
||||
bind:receiverForm
|
||||
{receiverSubmitting}
|
||||
{receiverHeadersError}
|
||||
{receiverBotChats}
|
||||
{receiverTesting}
|
||||
{receiverLabel}
|
||||
onopenReceiverForm={openReceiverForm}
|
||||
onsaveReceiver={saveReceiver}
|
||||
oncancelReceiver={() => addingReceiverForTarget = null}
|
||||
ontoggleReceiver={toggleReceiver}
|
||||
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
|
||||
ontestReceiver={testReceiver}
|
||||
onloadBotChats={loadReceiverBotChats}
|
||||
onchangeReceiverForm={(f) => receiverForm = f}
|
||||
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -529,3 +786,113 @@
|
||||
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
||||
oncancel={() => confirmDeleteReceiver = null}
|
||||
/>
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.targets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.target-group {
|
||||
display: block;
|
||||
}
|
||||
.target-group__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
|
||||
margin-left: 0.55rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.target-group__items {
|
||||
padding-left: 0.4rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.target-summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.1rem 0.25rem 0.1rem 0;
|
||||
margin: -0.1rem 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.target-summary {
|
||||
flex: 0 1 auto;
|
||||
max-width: 32rem;
|
||||
}
|
||||
}
|
||||
.target-summary:hover {
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.target-summary:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.target-summary__chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-muted-foreground);
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
|
||||
}
|
||||
.target-summary__chevron.open {
|
||||
transform: rotate(90deg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.target-summary__icon {
|
||||
color: var(--color-primary);
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.target-summary__name {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.target-summary__count {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.target-summary__count-num {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.target-summary__count-label {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.target-summary__count--empty {
|
||||
font-style: italic;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
background: transparent;
|
||||
padding: 0.12rem 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
targetCount: number;
|
||||
typeBadge?: string | null;
|
||||
botHref?: string | null;
|
||||
botEntityId?: number | null;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
icon,
|
||||
name,
|
||||
subtitle = null,
|
||||
targetCount,
|
||||
typeBadge = null,
|
||||
botHref = null,
|
||||
botEntityId = null,
|
||||
muted = false,
|
||||
}: Props = $props();
|
||||
|
||||
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
|
||||
</script>
|
||||
|
||||
<div class="bot-group-header" class:muted>
|
||||
<div class="bot-avatar">
|
||||
<MdiIcon name={icon} size={18} />
|
||||
</div>
|
||||
<div class="bot-meta">
|
||||
<div class="bot-title-row">
|
||||
<span class="bot-name">{name}</span>
|
||||
{#if typeBadge}
|
||||
<span class="type-badge">{typeBadge}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if subtitle}
|
||||
<span class="bot-sub">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bot-actions">
|
||||
<span class="count-chip">
|
||||
<span class="count-num">{targetCount}</span>
|
||||
<span class="count-label">{countLabel}</span>
|
||||
</span>
|
||||
{#if botHref}
|
||||
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bot-group-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
|
||||
margin: 1.4rem 0 0.55rem 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(
|
||||
95deg,
|
||||
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
|
||||
var(--color-glass) 75%
|
||||
);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(150%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bot-group-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12%;
|
||||
bottom: 12%;
|
||||
width: 3px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-primary),
|
||||
color-mix(in srgb, var(--color-primary) 35%, transparent)
|
||||
);
|
||||
}
|
||||
.bot-group-header.muted {
|
||||
background: var(--color-glass);
|
||||
}
|
||||
.bot-group-header.muted::before {
|
||||
background: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.muted .bot-avatar {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.bot-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
.bot-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.bot-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.type-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.bot-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bot-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.count-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
}
|
||||
.count-num {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.count-label {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.bot-group-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -114,34 +114,37 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Inline add-receiver form -->
|
||||
{#if addingReceiverForTarget === target.id}
|
||||
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
|
||||
{#if target.type === 'telegram'}
|
||||
{@const botId = target.config?.bot_id}
|
||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
|
||||
value: c.chat_id,
|
||||
label: c.title || c.username || c.chat_id,
|
||||
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
||||
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
|
||||
disabled: existingKeys.has(c.chat_id),
|
||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||
}))}
|
||||
{#if addingReceiverForTarget === target.id}
|
||||
<EntitySelect
|
||||
items={chatItems}
|
||||
bind:value={receiverForm.chat_id}
|
||||
open={true}
|
||||
showTrigger={false}
|
||||
placeholder={t('telegramBot.selectChat')}
|
||||
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
|
||||
onclose={oncancelReceiver}
|
||||
/>
|
||||
{/if}
|
||||
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
|
||||
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
{t('targets.addReceiver')}
|
||||
</button>
|
||||
{:else if addingReceiverForTarget === target.id}
|
||||
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
||||
{#if target.type === 'telegram'}
|
||||
{@const botId = target.config?.bot_id}
|
||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
|
||||
value: c.chat_id,
|
||||
label: c.title || c.username || c.chat_id,
|
||||
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
||||
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
|
||||
disabled: existingKeys.has(c.chat_id),
|
||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||
}))}
|
||||
{#if chatItems.length > 0}
|
||||
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
|
||||
{:else}
|
||||
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{/if}
|
||||
{#if botId}
|
||||
<button type="button" onclick={() => onloadBotChats(botId)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.discoverChats')}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if target.type === 'email'}
|
||||
{#if target.type === 'email'}
|
||||
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
|
||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if target.type === 'webhook'}
|
||||
|
||||
@@ -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,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -19,11 +21,15 @@
|
||||
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 MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import type { TemplateConfig } from '$lib/types';
|
||||
|
||||
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
||||
@@ -41,6 +47,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>>({});
|
||||
@@ -58,7 +75,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);
|
||||
@@ -162,8 +196,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));
|
||||
@@ -195,7 +237,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([
|
||||
@@ -205,10 +262,67 @@
|
||||
supportedLocalesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
|
||||
// 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];
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
refreshDateFormatPreview();
|
||||
}
|
||||
function edit(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
@@ -219,7 +333,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);
|
||||
@@ -235,6 +350,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,
|
||||
@@ -247,25 +421,77 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: config.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const slotCount = Object.keys(config.slots || {}).length;
|
||||
tiles.push({
|
||||
icon: 'mdiViewGridOutline',
|
||||
value: String(slotCount),
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
}
|
||||
if (locales.size > 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiTranslate',
|
||||
value: String(locales.size),
|
||||
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
|
||||
hint: [...locales].sort().join(', '),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
if (config.user_id === 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiShieldStarOutline',
|
||||
label: t('common.system'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</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>
|
||||
@@ -282,7 +508,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>
|
||||
@@ -294,7 +520,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}
|
||||
@@ -305,19 +531,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 -->
|
||||
@@ -338,9 +576,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]}
|
||||
@@ -350,6 +588,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="slot-{slot.key}">
|
||||
<CollapsibleSlot
|
||||
label={slot.key}
|
||||
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
|
||||
@@ -368,6 +607,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]}
|
||||
@@ -380,12 +624,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>
|
||||
@@ -422,24 +667,25 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<MetaStrip tiles={templateConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
@@ -455,6 +701,16 @@
|
||||
<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 -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -11,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';
|
||||
@@ -21,13 +26,151 @@
|
||||
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';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
/** 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('');
|
||||
@@ -48,17 +191,90 @@
|
||||
});
|
||||
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 trackingConfigTiles(config: Record<string, any>): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const desc = getDescriptor(config.provider_type);
|
||||
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
|
||||
tiles.push({
|
||||
icon: 'mdiPulse',
|
||||
value: String(events.length),
|
||||
label: t('trackingConfig.eventTracking'),
|
||||
hint: events.map(f => t(f.label)).join(', ') || undefined,
|
||||
tone: events.length > 0 ? 'lavender' : 'default',
|
||||
});
|
||||
if (config.periodic_enabled) {
|
||||
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
|
||||
}
|
||||
if (config.scheduled_enabled) {
|
||||
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
|
||||
}
|
||||
if (config.memory_enabled) {
|
||||
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
|
||||
}
|
||||
if (config.quiet_hours_start && config.quiet_hours_end) {
|
||||
tiles.push({
|
||||
icon: 'mdiWeatherNight',
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
hint: t('trackingConfig.quietHoursStart'),
|
||||
tone: 'citrus',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
nameManuallyEdited = true;
|
||||
editing = c.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -72,19 +288,32 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</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>
|
||||
@@ -101,13 +330,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}
|
||||
@@ -155,10 +384,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)}
|
||||
@@ -175,14 +414,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.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') ? 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}
|
||||
@@ -224,25 +481,26 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
{@const desc = getDescriptor(config.provider_type)}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={trackingConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
@@ -257,7 +515,65 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<Modal open={previewModal !== null}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
onclose={() => previewModal = null}>
|
||||
{#if previewModal}
|
||||
{#if previewLocales.length > 1}
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each previewLocales as loc}
|
||||
<button type="button"
|
||||
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
|
||||
disabled={previewLoading}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('trackingConfig.previewSampleNote')}
|
||||
</p>
|
||||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
placeholder caused a one-frame layout jump as the modal shrank and
|
||||
then re-expanded. -->
|
||||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||
{#if previewModal.error}
|
||||
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
{previewModal.error}
|
||||
</div>
|
||||
{:else if previewModal.rendered}
|
||||
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-3">
|
||||
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
|
||||
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
|
||||
{t('trackingConfig.editTemplate')}
|
||||
</button>
|
||||
<button type="button" onclick={() => previewModal = null}
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
: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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { User } from '$lib/types';
|
||||
|
||||
const auth = getAuth();
|
||||
@@ -31,6 +32,13 @@
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
// Admin edit username/role
|
||||
let editUserId = $state<number | null>(null);
|
||||
let editUsername = $state('');
|
||||
let editRole = $state('user');
|
||||
let editMsg = $state('');
|
||||
let editSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
@@ -56,6 +64,20 @@
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
function openEditUser(user: any) {
|
||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||
}
|
||||
async function saveUserEdit(e: SubmitEvent) {
|
||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||
try {
|
||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||
editMsg = t('snack.userUpdated');
|
||||
editSuccess = true;
|
||||
snackSuccess(editMsg);
|
||||
await load();
|
||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
@@ -66,9 +88,41 @@
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
||||
}
|
||||
|
||||
function userTiles(user: User): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const isAdmin = user.role === 'admin';
|
||||
tiles.push({
|
||||
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
||||
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
||||
tone: isAdmin ? 'orchid' : 'sky',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiCalendarOutline',
|
||||
label: parseDate(user.created_at).toLocaleDateString(),
|
||||
hint: t('users.joined'),
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
if (user.id === auth.user?.id) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountStar',
|
||||
label: t('users.you', 'you'),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('users.title')} 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>
|
||||
@@ -105,15 +159,17 @@
|
||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<p class="font-medium truncate">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={userTiles(user)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
@@ -144,5 +200,28 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Admin edit username/role modal -->
|
||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||
<div>
|
||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="edit-username" bind:value={editUsername} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="edit-role" bind:value={editRole}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editMsg}
|
||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -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.1.0"
|
||||
version = "0.8.0"
|
||||
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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user