Merge remote-tracking branch 'origin/dev' into feat/native-apps

This commit is contained in:
jubnl
2026-08-25 23:47:41 +02:00
618 changed files with 10212 additions and 4196 deletions
+5
View File
@@ -2,6 +2,11 @@ name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
+4
View File
@@ -9,6 +9,10 @@ on:
permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
+272 -81
View File
@@ -1,46 +1,91 @@
name: Tests
name: Tests & SonarQube
permissions:
contents: read
# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so
# each test suite runs once per event instead of once for the old test.yml and
# once more for Sonar's coverage. Pushes to dev and main produce branch
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs don't receive secrets, so the two jobs that need them skip
# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN).
# Everything else runs on fork PRs so outside contributors still get the
# full test signal.
#
# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually
# exclusive with CI-based analysis, and it cannot import the lcov coverage
# this workflow produces.
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
# A PR that touches none of these can't change the tests or the analysis,
# so don't spend the coverage fan-out on it. Pushes stay unfiltered so the
# branch view is always refreshed from the merge result.
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile, so
# a dependency bump touches none of the four directories above — and a
# native binding like better-sqlite3 is exactly what needs the suite.
# npm workspaces hoist every dependency into the single root lockfile,
# so a dependency bump touches none of the four directories above — and
# a native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'docker-compose.yml'
- '.github/workflows/test.yml'
- 'sonar-project.properties'
- '.github/workflows/sonarqube.yml'
workflow_dispatch:
# A newer push to the same branch/PR supersedes the running one — cancel it
# rather than spending the coverage fan-out on a stale commit. Safe here
# because the Sonar scan job runs last and only the latest commit matters.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
plugin-facts:
name: Plugin Permission Facts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime (same choice as the plugin-sdk
# jobs and publish-plugin-sdk.yml).
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's
# generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it
# and checked in, because publish-plugin-sdk builds from a standalone checkout with
# no server present. This fails the PR if a copy drifts, if a new permission has no
# admin.plugins.perm.* translation, or if the CLI has no hint for it.
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
@@ -58,32 +103,9 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
shared-contracts:
name: Shared Contracts (Zod)
server-quality:
name: Server Types & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -96,18 +118,15 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck shared
# Rides along here rather than in shared-coverage: this job already
# pays the root install and shared build, and a coverage job may only
# fail because tests failed (it gates the scan).
run: cd shared && npm run typecheck
- name: Build server (tsc -> dist)
run: cd server && npm run build
@@ -123,30 +142,73 @@ jobs:
- name: Typecheck tests
# tests/ is outside the build tsconfig, so a service that grows a
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at runtime,
# if anything reads it at all. This is the step that catches it.
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
run: cd server && npm run typecheck:tests
- name: Lint
run: cd server && npm run lint:check
- name: Run tests
run: cd server && npm run test:coverage
client-quality:
name: Client Types & Lint
runs-on: ubuntu-latest
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
name: backend-coverage
path: server/coverage/
retention-days: 7
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Lint
run: cd client && npm run lint:check
- name: Page pattern check
run: cd client && npm run lint:pages
plugin-sdk-quality:
name: Plugin SDK Types
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
s3-contract:
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-latest
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
# and the AIStor container would fail to boot.
if: github.event.pull_request.head.repo.full_name == github.repository
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. Pushes and manual dispatches do
# have secrets, so only fork PRs are filtered out. (The implicit success()
# on this expression also keeps the job skipped when a preflight fails.)
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v6
@@ -210,14 +272,10 @@ jobs:
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
client-quality:
# Split out of client-tests: the suite takes ~11 minutes, and having the
# gates in front of it meant a single lint finding threw away the whole test
# signal for that run. Both jobs pay the install/build, which is cheap next
# to running the two in series.
name: Client Types & Lint
server-coverage:
name: Server Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -228,24 +286,34 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Lint
run: cd client && npm run lint:check
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
retention-days: 1
- name: Page pattern check
run: cd client && npm run lint:pages
client-tests:
name: Client Tests
client-coverage:
name: Client Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -255,19 +323,142 @@ jobs:
cache: npm
cache-dependency-path: package-lock.json
# Only the shared and client workspaces — a full root npm ci is not
# needed, and the scoped install keeps @testing-library/dom (a peer of
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
name: sonar-client-coverage
path: client/coverage/
retention-days: 7
retention-days: 1
shared-coverage:
name: Shared Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Run tests with coverage
run: cd shared && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
retention-days: 1
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise (same choice as publish-plugin-sdk.yml).
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
retention-days: 1
scan:
name: Scan
runs-on: ubuntu-latest
# Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
with:
# Sonar reads git blame to date each issue and to decide what counts
# as new code. A shallow clone leaves it blind to both.
fetch-depth: 0
- name: Download server coverage
uses: actions/download-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
- name: Download client coverage
uses: actions/download-artifact@v6
with:
name: sonar-client-coverage
path: client/coverage/
- name: Download shared coverage
uses: actions/download-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
- name: Download plugin-sdk coverage
uses: actions/download-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
# liketrek org.
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
# New Code is configured as "previous version", so this string is what
# draws that line — changing it re-bases every "new code" metric.
# Pinned rather than read from package.json on purpose: package.json is
# still on 3.4.1 while the project has been analysed as 4.0.0 all along,
# and taking the lower number would silently move the baseline. Bump it
# here as part of the release, together with the version bump.
#
# Everything else — sources, tests, exclusions, the documented
# false-positive list — lives in sonar-project.properties so it stays
# reviewable in a PR rather than buried in workflow YAML.
args: -Dsonar.projectVersion=4.0.0
+13 -14
View File
@@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces:
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
@@ -17,16 +16,16 @@ Each package has its own `CLAUDE.md` with internals; this file covers the monore
## Commands
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
npm run build # build shared → nest-mcp → server → client (order matters)
npm run lint # lint all four workspaces
npm run format # prettier --write all four
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run format # prettier --write all three
```
Tests (the user runs client tests as `cd client && npm run test`):
Tests:
```bash
npm run test # all workspaces
@@ -35,7 +34,7 @@ npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage, server + client
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
@@ -89,12 +88,12 @@ Offline-first, with a layered data flow. A Page never owns state directly:
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
## Project direction (from the 2026 full-repo audit)
## Project direction
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written:
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's feature components calling the raw API past the offline core, still ~190 files). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
@@ -105,7 +104,7 @@ A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
+18 -253
View File
@@ -21,10 +21,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
&nbsp;
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
&nbsp;
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
@@ -33,7 +37,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
---
@@ -238,64 +241,15 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
with secure defaults and every option documented inline. Download it, then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
for the full walkthrough.
<br />
@@ -309,6 +263,8 @@ helm install trek trek/trek
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
<br />
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
@@ -323,36 +279,10 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating
**Docker Compose:**
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<br />
<h2 id="reverse-proxy">Reverse Proxy</h2>
@@ -432,175 +362,8 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
> [!NOTE]
> Variables are validated at startup (fail-fast). An unset or blank variable
> always falls back to its default, but a variable set to a malformed value
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
> with a report listing every offending variable. Boolean switches accept
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
<details>
<summary><b>Full reference</b></summary>
<br />
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
</details>
<br />
<h2 id="storage">Storage</h2>
TREK separates *what* it stores (eight content categories — trip documents,
journey photos, cover images, profile pictures, place images, the two photo
caches, and backups) from *where* it stores it (named backends). Out of the
box everything lives on local disk (`uploads/` and `data/backups`). From
**Admin → Storage** you can:
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
MinIO/AIStor) and assign any category to them.
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
then also copied to each target. The classic off-site backup setup is:
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
target, save. Replica writes happen one after another during each upload, so
a slow or unreachable target slows every upload of every category on that
backend — fine for backups, worth weighing for hot categories. Existing
objects are not replicated when you add a target — press **Sync now** on
the backend to copy them (one sync at a time; progress and cancel in the
panel).
- **Watch the Health strip**: replica failures never fail the original
request; they are recorded and shown there instead. All-clear means every
replicated write landed. Failures also notify admins over the configured
notification channels (first failure immediately, then hourly summaries) —
tune it under Admin → Notifications.
- **Test** any backend from its row (for a replicated backend it probes the
primary and each target individually). Targets must be saved before Test
can probe them.
- **Usage**: per-category and per-backend object counts and sizes, scanned
nightly and on demand.
Backend credentials are encrypted at rest. Without an explicit
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
explicitly is recommended for credentialed backends. On an existing install
that has been running without an explicit key, do not just set a new one —
that would orphan already-stored secrets; use the key-rotation procedure (see
*Rotating the Encryption Key* under Updating) to move to an explicit key.
Notes and limits: reassigning a populated category prompts to move its
existing objects — accepting copies everything to the new backend, flips the
category, then sweeps any writes that raced the copy; source objects are kept
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
declining routes only new writes to the new backend, leaving old objects in
place as before. Only one storage job (a mirror sync or a category migration)
runs at a time, cancelling before the flip is always safe, and a failed copy
never flips the category; media categories on S3 work but every served byte
proxies through the server — the proxy answers conditional GETs (ETag /
Last-Modified) and a single `Range` request, while a multi-range request is
answered with the full object; AWS buckets
with Object-Lock or checksum-requiring policies
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
The legacy `/uploads/photos` directory from older TREK versions is still
served and included in backups, but it is not a configurable category.
Running the storage contract suite against a live MinIO/AIStor is a
license-gated procedure — CI runs it automatically for same-repo pull
requests (fork PRs skip it, since they have no access to the license
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
header for the manual procedure.
### Provisioning at first boot (seed file)
For infrastructure-as-code setups, mount a `storage-config.json` into the
data directory — it is imported **once**, on the first boot that has no
stored storage configuration, and loudly ignored afterwards:
```jsonc
// storage-config.json — secrets may be plaintext (encrypted on import)
// or already-encrypted enc:v1: values.
{
"backends": [
{ "name": "off-site", "type": "s3", "options": {
"endpoint": "https://s3.example.com", "bucket": "trek",
"accessKeyId": "…", "secretAccessKey": "…" } },
{ "name": "backups-mirror", "type": "mirror", "options": {
"primary": "backups-local", "replicas": ["off-site"] } }
],
"categories": { "backups": "backups-mirror" }
}
```
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
```yaml
# docker-compose: add under the trek service's volumes
- ./storage-config.json:/app/data/storage-config.json:ro
```
An invalid seed file aborts boot with the exact validation error — an
actively-provisioning operator sees the problem instead of a silent default.
### Recovery and restore
To reset storage configuration to the built-in defaults (or to re-import a
seed file): stop the server, run
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
and start it again.
Backups include the storage configuration (it lives in the database). If your
only backup sits on S3 and the credentials for it sit inside that backup:
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
mount a seed file), then restore the backup from the Backup panel.
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
## Data sources
@@ -609,6 +372,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+77
View File
@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
```
Single test:
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
## Direction (from the 2026 audit)
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
## Other big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
+25 -4
View File
@@ -111,7 +111,7 @@ describe('client > request interceptor', () => {
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
})
it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => {
it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => {
const realCrypto = globalThis.crypto
vi.stubGlobal('crypto', {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
@@ -120,9 +120,30 @@ describe('client > request interceptor', () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
const key = String(sink[0].headers['X-Idempotency-Key'])
expect(key).toMatch(/^[a-z0-9]+$/)
expect(key).not.toMatch(/-/)
// randomUUID needs a secure context, so on the plain-http installs the
// README documents this branch is what actually runs. getRandomValues has no
// such requirement, so the fallback is still a full v4 UUID.
expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
)
})
it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => {
vi.stubGlobal('crypto', undefined)
const keys = new Set<string>()
for (let i = 0; i < 50; i++) {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
keys.add(String(sink[0].headers['X-Idempotency-Key']))
}
// The old fallback was Math.random().toString(36).slice(2), which is not
// length-stable: 0.5 yields a single character and 0 yields the empty string.
// An empty key makes the server skip deduplication entirely, so a retried
// write applies twice.
for (const k of keys) expect(k.length).toBeGreaterThan(16)
expect(keys.size).toBe(50)
})
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
+8 -5
View File
@@ -1,6 +1,7 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
@@ -151,10 +152,7 @@ apiClient.interceptors.request.use(
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
config.headers['X-Idempotency-Key'] = randomId()
}
return config
},
@@ -533,7 +531,12 @@ export const adminApi = {
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
pluginUpdate: (id: string, version?: string) =>
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
// Release a per-plugin update hold (set by a deliberate non-latest install).
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
@@ -98,7 +98,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
render(<AddonManager />);
await screen.findByText('No addons available');
expect(await screen.findByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
+10 -9
View File
@@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
<button type="button" onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
@@ -222,7 +222,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
@@ -269,7 +269,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
<button type="button"
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
@@ -451,6 +451,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<span className="sr-only">Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
@@ -459,7 +460,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
@@ -470,7 +471,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
@@ -509,11 +510,11 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
@@ -526,7 +527,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
@@ -589,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button
<button type="button"
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
render(<AdminMcpTokensPanel />);
await screen.findByText('No MCP tokens have been created yet');
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
});
});
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
show less
</button>
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -214,16 +214,21 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
<button type="button" onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
@@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
<button type="button" onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
@@ -201,6 +201,9 @@ function registryEntry(over: Record<string, unknown> = {}) {
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
// The real browse response always carries latestCompatible (server-side hostCompat);
// mirror `latest` by default, exactly like a fully-compatible entry.
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
...over,
}
}
@@ -1087,6 +1090,235 @@ describe('AdminPluginsPanel — updates', () => {
})
})
/**
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
* computed server-side), never the absolute newest published version — a banner counting
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
*/
describe('AdminPluginsPanel — compatible updates only', () => {
const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
})
})
/**
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
* which takes it out of the banner count and "Update all" — a rollback the admin just
* made must not be nagged straight back. The row says so and offers to resume.
*/
describe('AdminPluginsPanel — update hold', () => {
const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
let resumed = false
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
resumed = true
return HttpResponse.json({ updateHold: false })
}))
withToast()
fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
await waitFor(() => expect(resumed).toBe(true))
expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
const updated: string[] = []
panelWith(
[held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
{ registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
)
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
updated.push(String(params.id))
return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
}))
withToast()
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
})
})
/**
* The version picker (#plugins): install any published version from the Discover detail
* modal, and roll an installed plugin back through the update pipeline (which owns the
* stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version —
* the picker only renders them, it never re-derives range logic.
*/
describe('AdminPluginsPanel — version picker', () => {
const VERSIONS = [
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
]
const detailWithVersions = () => ({
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
})
function mockDiscoverDetail() {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () =>
HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
)
}
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
let body: unknown = null
mockDiscoverDetail()
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
}))
withToast()
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
mockDiscoverDetail()
render(<AdminPluginsPanel />)
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
panelWith([
plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
const current = await screen.findByTestId('version-row-2.0.0')
expect(within(current).getByText('Installed')).toBeInTheDocument()
expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
})
})
describe('AdminPluginsPanel — capability and dependency chips', () => {
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
panelWith([plugin({
@@ -1405,7 +1637,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
@@ -1423,7 +1655,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
@@ -1880,8 +2112,8 @@ describe('AdminPluginsPanel — update all', () => {
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
})),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
])),
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
started.push(String(params.id))
+229 -51
View File
@@ -5,7 +5,7 @@ import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Globe, Image, CalendarDays, Bell,
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
@@ -63,6 +63,8 @@ interface PluginRow {
keyFingerprint?: string | null
/** Why an update was refused, if one was. `version` is the version that was refused. */
updateBlock?: { code: string; detail: string | null; version: string | null } | null
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
updateHold?: boolean
}
interface RegistryItem {
id: string
@@ -93,9 +95,21 @@ interface RegistryItem {
/** The full key — public, and carried in full because re-trust compares it exactly. */
authorPublicKey?: string | null
}
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
interface VersionInfo {
version: string
publishedAt: string | null
size: number | null
signed: boolean
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
trek: string | null
compatible: boolean
}
interface RegistryDetail extends RegistryItem {
size: number | null
publishedAt: string | null
/** Every published version, newest first — the version picker's data. */
versions?: VersionInfo[]
manifest: {
// Optional because a registry may omit an empty list — the detail view has to
// degrade rather than throw halfway through its render.
@@ -187,7 +201,7 @@ const PERM_KEYS = PLUGIN_PERMISSIONS
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
function isNewer(a: string, b: string): boolean {
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0)
const pa = nums(a), pb = nums(b)
for (let i = 0; i < 3; i++) {
const x = pa[i] || 0, y = pb[i] || 0
@@ -392,6 +406,12 @@ export default function AdminPluginsPanel() {
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
// from the registry detail endpoint, which computes each version's compat verdict.
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
// A picked version OLDER than the installed one — held here until the admin consents
// to the data risk (the plugin's data dir stays; older code may not understand it).
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
@@ -420,10 +440,13 @@ export default function AdminPluginsPanel() {
// Index the registry once per fetch: the version map the update badges read, plus the
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
// The map holds latestCompatible — the newest version THIS TREK can install (computed
// server-side) — not the absolute latest, so the banner never counts an update the
// update endpoint would refuse.
const indexRegistry = (items: RegistryItem[]) => {
const vers: Record<string, string> = {}
const byId: Record<string, RegistryItem> = {}
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
setLatest(vers)
setRegById(byId)
}
@@ -551,7 +574,21 @@ export default function AdminPluginsPanel() {
.catch(() => setErrorsFor({ id, rows: [] }))
}
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
// the admin just rolled it back, and the banner nagging them straight back would make
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
// wondering why no update shows. Null when the registry gives no range to point at.
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
const reg = regById[p.id]
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
if (latest[p.id] === reg.latest) return null
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
@@ -639,9 +676,11 @@ export default function AdminPluginsPanel() {
.finally(() => { setBusy(null); refresh() })
}
const runUpdate = (p: PluginRow) => {
// `version` pins the exact version to install (rollback / explicit switch); omitted,
// the server resolves the newest TREK-compatible version itself.
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
return adminApi.pluginUpdate(p.id)
return adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
@@ -673,6 +712,27 @@ export default function AdminPluginsPanel() {
.finally(() => { setRetrusting(false); refresh() })
}
// "Change version…" on an installed row: fetch the per-version list (with the server's
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
// response can't populate a picker that was reopened for another row.
const openVersionPicker = (p: PluginRow) => {
setMenu(null)
setVersionPicker({ plugin: p, versions: null, failed: false })
adminApi.pluginDetail(p.id)
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
}
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
// that asks for explicit consent first. Any other switch is the ordinary update path.
const pickVersion = (version: string) => {
if (!versionPicker) return
const p = versionPicker.plugin
setVersionPicker(null)
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
else void runUpdate(p, version)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
@@ -739,7 +799,7 @@ export default function AdminPluginsPanel() {
</div>
)}
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
{menu && <div role="presentation" className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
{/* Header */}
<div className="px-4 sm:px-6 pt-5">
<div className="flex items-start justify-between gap-4">
@@ -795,11 +855,11 @@ export default function AdminPluginsPanel() {
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
</div>
<div className="sm:hidden flex items-center gap-2 shrink-0">
<button onClick={pickUpload} title={t('admin.plugins.upload')}
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<UploadCloud size={15} />
</button>
<button onClick={rescan} title={t('admin.plugins.rescan')}
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<RefreshCw size={15} />
</button>
@@ -842,11 +902,11 @@ export default function AdminPluginsPanel() {
valueLabel={sortLabel(sort, t)}
onPick={v => setSort(v as SortKey)} />
<button onClick={pickUpload} title={t('admin.plugins.upload')}
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<UploadCloud size={15} />
</button>
<button onClick={rescan} title={t('admin.plugins.rescan')}
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<RefreshCw size={15} />
</button>
@@ -871,7 +931,7 @@ export default function AdminPluginsPanel() {
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
<ArrowUpCircle size={16} className="text-warning shrink-0" />
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
<button onClick={() => void updateAll()}
<button type="button" onClick={() => void updateAll()}
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
{t('admin.plugins.updateAll')}
</button>
@@ -885,9 +945,12 @@ export default function AdminPluginsPanel() {
) : shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
onChangeVersion={() => openVersionPicker(p)}
onResume={() => void resumeUpdates(p)}
onReviewBlock={() => setSignatureBlock({
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
@@ -909,11 +972,11 @@ export default function AdminPluginsPanel() {
{/* Error-log modal */}
{errorsFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
<button onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
<button type="button" onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-4 overflow-y-auto text-xs font-mono">
{errorsFor.rows.length === 0 ? <p className="text-content-faint py-4 text-center">{t('admin.plugins.noErrors')}</p> :
@@ -931,11 +994,11 @@ export default function AdminPluginsPanel() {
{/* Operator-supplied egress hosts */}
{egressFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
<button onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
{!egressFor.supported ? (
@@ -949,7 +1012,7 @@ export default function AdminPluginsPanel() {
{egressFor.hosts.map(h => (
<div key={h} className="flex items-center justify-between gap-2 rounded-lg border border-edge-secondary px-3 py-2">
<span className="text-sm font-mono text-content break-all">{h}</span>
<button
<button type="button"
disabled={egressSaving}
onClick={() => saveEgress(egressFor.hosts.filter(x => x !== h))}
className="text-content-faint hover:text-danger disabled:opacity-50"
@@ -964,7 +1027,7 @@ export default function AdminPluginsPanel() {
placeholder="gotify.example.com"
className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
/>
<button
<button type="button"
disabled={egressSaving || !egressDraft.trim()}
onClick={() => saveEgress([...egressFor.hosts, egressDraft.trim()])}
className="rounded-lg bg-content px-3 py-2 text-sm text-surface disabled:opacity-50"
@@ -990,6 +1053,20 @@ export default function AdminPluginsPanel() {
message={t('admin.plugins.uninstallBody')}
/>
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
isOpen={!!confirmDowngrade}
onClose={() => setConfirmDowngrade(null)}
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }}
title={t('admin.plugins.downgradeTitle')}
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
confirmLabel={t('admin.plugins.downgradeConfirm')}
/>
{signatureBlock && (
<SignatureBlockDialog
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
@@ -1043,7 +1120,7 @@ function sortLabel(s: SortKey, t: T): string {
function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) {
return (
<button onClick={onClick} role="tab" aria-selected={active}
<button type="button" onClick={onClick} role="tab" aria-selected={active}
className={`inline-flex items-center gap-2 text-[13px] font-medium px-3.5 py-1.5 rounded-lg transition-colors ${
active ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}>
{label}
@@ -1064,7 +1141,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
const active = value !== options[0]?.[0]
return (
<div className="relative">
<button onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
<button type="button" onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
className={`relative h-[38px] w-[38px] sm:w-auto px-0 sm:px-3 inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl border bg-surface-card text-[13px] text-content-secondary transition-colors whitespace-nowrap hover:border-content-faint ${
active ? 'border-content-faint' : 'border-edge'}`}>
{icon}
@@ -1076,7 +1153,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
{open && (
<div className="absolute top-11 right-0 z-30 min-w-[180px] max-w-[calc(100vw-2rem)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
{options.map(([v, lbl]) => (
<button key={v} onClick={() => { onPick(v); setMenu(null) }}
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors hover:bg-surface-tertiary ${
value === v ? 'text-content font-semibold' : 'text-content-secondary'}`}>
{lbl}<Check size={15} className={`ml-auto text-accent ${value === v ? 'opacity-100' : 'opacity-0'}`} />
@@ -1095,17 +1172,17 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
<PackageOpen size={26} className="text-content-faint" />
</div>
<p className="text-sm font-medium text-content-muted">{t('admin.plugins.empty')}</p>
<button onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
<button type="button" onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
<Download size={14} /> {t('admin.plugins.tabDiscover')}
</button>
</div>
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
@@ -1158,12 +1235,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-warning">
<ShieldAlert size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
<button onClick={onReviewBlock}
<button type="button" onClick={onReviewBlock}
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity">
{t('admin.plugins.reviewBlock')}
</button>
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
<Info size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
<PauseCircle size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={busy === p.id}
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
@@ -1181,7 +1278,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
{p.operatorEgress && (
<button
<button type="button"
onClick={onEgress}
title={t('admin.plugins.allowedHosts.hint')}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border transition-colors ${
@@ -1211,7 +1308,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
{hasUpdate && (
<button onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
<button type="button" onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-2 sm:px-2.5 py-1.5 rounded-full text-warning bg-warning-soft border border-warning/30 hover:opacity-90 transition-opacity disabled:opacity-50">
<ArrowUpCircle size={13} /> <span className="hidden sm:inline">{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
@@ -1221,7 +1318,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
<div className="relative">
<button ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
<MoreHorizontal size={17} />
</button>
@@ -1233,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
@@ -1275,7 +1376,7 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties {
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick}
<button type="button" onClick={onClick}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors ${
danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary hover:bg-surface-tertiary'}`}>
{icon} {label}
@@ -1349,7 +1450,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<Download size={11} /> {formatCompactCount(item.downloadCount)}
</span>
)}
<button onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
@@ -1407,12 +1508,12 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
const offer = installOffer(detail ?? item, t)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
<div className="relative">
<Screenshot url={item.screenshotUrl} className="w-full aspect-[16/9]" iconSize={36} />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<button onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
<button type="button" onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
</div>
<div className="flex items-start gap-3 sm:gap-3.5 px-4 sm:px-5 -mt-7 relative z-[1]">
@@ -1426,7 +1527,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
<button onClick={() => onInstall(item.id, offer.version)}
<button type="button" onClick={() => onInstall(item.id, offer.version)}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
@@ -1509,7 +1610,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.detailsTitle')}</h4>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
{sizeKb !== null && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
{(item.trek || item.minTrekVersion) && (
@@ -1521,6 +1622,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)}
</div>
</div>
{/* Every published version, installable individually. The compat verdict per
version is SERVER-computed — an incompatible one is explained, never offered. */}
{(detail?.versions?.length ?? 0) > 1 && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.versionsTitle')}</h4>
<div className="mt-2 space-y-0.5">
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
<span className="ml-auto" />
{v.compatible ? (
!installed && (
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
{t('admin.plugins.installCompatible', { version: v.version })}
</button>
)
) : (
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
@@ -1548,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) {
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
}
/**
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
* verdict: the installed version is marked, a compatible one gets a Switch action (the
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
}) {
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
</div>
<div className="p-2">
{failed && <p className="text-xs text-danger px-2.5 py-2">{t('admin.plugins.detailError')}</p>}
{!failed && !versions && <p className="text-xs text-content-faint px-2.5 py-2">{t('common.loading')}</p>}
{versions?.length === 0 && <p className="text-xs text-content-faint px-2.5 py-2">{t('admin.plugins.noVersions')}</p>}
{versions?.map(v => {
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-surface-secondary transition-colors">
<span className={`text-[13px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
<span className="ml-auto" />
{current ? (
<span className="text-[11.5px] font-medium text-content-faint">{t('admin.plugins.installed')}</span>
) : v.compatible ? (
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
{t('admin.plugins.versionSwitch', { version: v.version })}
</button>
) : (
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
/**
* A signature check refused an install/update.
*
@@ -1582,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
: 'admin.plugins.sig.invalidBody'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
<div>
@@ -1606,12 +1784,12 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button onClick={onClose}
<button type="button" onClick={onClose}
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
</button>
{offerRetrust && (
<button onClick={() => onRetrust(version, newKey)} disabled={busy}
<button type="button" onClick={() => onRetrust(version, newKey)} disabled={busy}
className="text-xs font-semibold px-4 py-2 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity disabled:opacity-50">
{t('admin.plugins.sig.retrustConfirm')}
</button>
@@ -1646,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
t: T; onApprove: () => void; onLater: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
<div>
@@ -1685,8 +1863,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
<button onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
</div>
</div>
</div>
@@ -1706,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
<div>
@@ -1726,7 +1904,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
: t('admin.plugins.dep.requires', { version: r.constraint })}
</div>
</div>
<button onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
<button type="button" onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-3 py-1.5 rounded-full bg-accent text-accent-text hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0">
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
</button>
@@ -1737,7 +1915,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<button onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
</div>
</div>
</div>
@@ -1763,7 +1941,7 @@ function SecurityInfo({ t }: { t: T }) {
<ShieldCheck size={14} className="text-content-faint shrink-0 mt-0.5" />
<p className="text-xs text-content-muted">{t('admin.plugins.reviewedMeaning')}</p>
</div>
<button onClick={() => setOpen(o => !o)}
<button type="button" onClick={() => setOpen(o => !o)}
className="w-full px-4 sm:px-6 py-2.5 border-t border-edge-secondary flex items-center justify-between gap-2 text-xs font-medium text-content-secondary hover:text-content hover:bg-surface-tertiary transition-colors">
<span className="flex items-center gap-2"><Lock size={13} className="shrink-0" /> <span className="text-left">{t('admin.plugins.security.title')}</span></span>
<ChevronDown size={15} className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
+23 -19
View File
@@ -191,7 +191,7 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
@@ -208,7 +208,7 @@ export default function BackupPanel() {
className="hidden"
onChange={handleUploadRestore}
/>
<button
<button type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
@@ -222,7 +222,7 @@ export default function BackupPanel() {
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
<button type="button"
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
@@ -247,7 +247,7 @@ export default function BackupPanel() {
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
@@ -274,14 +274,14 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
<button type="button"
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
<button type="button"
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
@@ -292,7 +292,7 @@ export default function BackupPanel() {
}
{t('backup.restore')}
</button>
<button
<button type="button"
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
@@ -318,11 +318,11 @@ export default function BackupPanel() {
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
<div className="min-w-0 text-sm font-medium text-gray-900">
{t('backup.auto.enable')}
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
<button type="button"
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
@@ -339,7 +339,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -386,7 +386,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -421,7 +421,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -440,7 +440,7 @@ export default function BackupPanel() {
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
<button type="button"
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
@@ -458,11 +458,15 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
// the backdrop itself carries no semantics of its own.
role="presentation"
className="bg-[rgba(0,0,0,0.5)]"
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
@@ -497,14 +501,14 @@ export default function BackupPanel() {
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
<button type="button"
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-002: shows Categories title', async () => {
render(<CategoryManager />);
await screen.findByText('Categories');
expect(await screen.findByText('Categories')).toBeInTheDocument();
});
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
render(<CategoryManager />);
await screen.findByText('No categories yet');
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-004: shows New Category button', async () => {
render(<CategoryManager />);
await screen.findByText('New Category');
expect(await screen.findByText('New Category')).toBeInTheDocument();
});
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-010: shows subtitle text', async () => {
render(<CategoryManager />);
await screen.findByText('Manage categories for places');
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
});
it('FE-COMP-CAT-011: category count is shown', async () => {
@@ -194,7 +194,7 @@ export default function CategoryManager() {
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
<button type="button" onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('categories.new')}</span>
@@ -235,11 +235,11 @@ export default function CategoryManager() {
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
<button type="button" onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
<button type="button" onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
</button>
@@ -86,7 +86,7 @@ function OptionButton({
children: React.ReactNode
}) {
return (
<button
<button type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -154,7 +154,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
<button type="button"
onClick={() => reset(field)}
className="text-xs ml-2 text-content-faint underline"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
@@ -185,7 +185,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
created_at: String(new Date()),
}], [])
if (!loaded) {
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
// The request handler never resolves, so sending stays true after the click settles
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
@@ -120,7 +120,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('No channel configured');
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
@@ -133,7 +133,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Failed');
expect(await screen.findByText('Failed')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -66,7 +66,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
}) => (
<button
<button type="button"
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
+2 -2
View File
@@ -461,7 +461,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
<button type="button"
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
>
@@ -484,7 +484,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
<button type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
@@ -18,7 +18,7 @@ beforeEach(() => {
resetAllStores();
});
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
function rowButtons(name: string, selector: string): HTMLElement[] {
const row = screen.getByText(name).closest(selector) as HTMLElement;
return within(row).getAllByRole('button');
@@ -250,7 +250,7 @@ describe('PackingTemplateManager', () => {
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Electronics{Enter}');
await screen.findByText('Electronics');
expect(await screen.findByText('Electronics')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
@@ -280,7 +280,7 @@ describe('PackingTemplateManager', () => {
await user.type(itemInput, 'Sandals');
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
await user.type(itemInput, '{Enter}');
await screen.findByText('Sandals');
expect(await screen.findByText('Sandals')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
@@ -312,7 +312,7 @@ describe('PackingTemplateManager', () => {
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
await screen.findByText('Shoes');
expect(await screen.findByText('Shoes')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
@@ -379,7 +379,7 @@ describe('PackingTemplateManager', () => {
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top{Enter}');
await screen.findByText('Tank Top');
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
@@ -588,11 +588,11 @@ describe('PackingTemplateManager', () => {
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(templateButtons('Beach Trip')[2]);
await user.click(templateButtons('Beach Trip')[3]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
await user.click(templateButtons('Beach Trip')[2]);
await user.click(templateButtons('Beach Trip')[3]);
await screen.findByText('Template deleted');
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
@@ -611,14 +611,14 @@ describe('PackingTemplateManager', () => {
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[1]);
await user.click(templateButtons('Beach Trip')[2]);
await user.clear(screen.getByDisplayValue('Beach Trip'));
await user.type(screen.getByRole('textbox'), '{Enter}');
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(puts).toBe(0);
// Blurring the field commits the pending name — here the request fails
await user.click(templateButtons('Beach Trip')[1]);
await user.click(templateButtons('Beach Trip')[2]);
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Winter');
@@ -158,7 +158,7 @@ export default function PackingTemplateManager() {
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(true)}
<button type="button" onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
@@ -171,8 +171,8 @@ export default function PackingTemplateManager() {
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
</div>
)}
@@ -187,7 +187,7 @@ export default function PackingTemplateManager() {
<div key={tmpl.id}>
{/* Template row */}
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="text-slate-400 flex-shrink-0" />
@@ -197,14 +197,14 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
) : (
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
)}
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
</span>
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
<button onClick={() => handleDeleteTemplate(tmpl.id)}
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
</div>
@@ -228,11 +228,11 @@ export default function PackingTemplateManager() {
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
<button onClick={() => handleDeleteCategory(cat.id)}
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
</div>
@@ -246,15 +246,15 @@ export default function PackingTemplateManager() {
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button onClick={() => handleDeleteItem(item.id)}
<button type="button" onClick={() => handleDeleteItem(item.id)}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
</>
)}
@@ -268,9 +268,9 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
</div>
)}
@@ -287,11 +287,11 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
<button type="button" onClick={() => setAddingCategory(true)}
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
),
);
renderPanel();
await screen.findByText('Error');
expect(await screen.findByText('Error')).toBeInTheDocument();
});
});
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={handleReset}
disabled={saving}
title={t('perm.resetDefaults')}
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
@@ -338,7 +338,7 @@ export default function AdminStoragePanel(): React.ReactElement {
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
@@ -364,7 +364,7 @@ export default function AdminStoragePanel(): React.ReactElement {
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
@@ -376,12 +376,12 @@ export default function AdminStoragePanel(): React.ReactElement {
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
@@ -433,7 +433,7 @@ export default function AdminStoragePanel(): React.ReactElement {
failed: String(backfill.failed),
})}
</p>
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
@@ -464,7 +464,7 @@ export default function AdminStoragePanel(): React.ReactElement {
</p>
)}
{syncPrompt !== row.name && (
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
@@ -480,14 +480,14 @@ export default function AdminStoragePanel(): React.ReactElement {
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
@@ -512,10 +512,10 @@ export default function AdminStoragePanel(): React.ReactElement {
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
@@ -552,7 +552,7 @@ export default function AdminStoragePanel(): React.ReactElement {
total: String(m.total),
})}
</p>
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
@@ -601,7 +601,7 @@ export default function AdminStoragePanel(): React.ReactElement {
onCancel={() => setEditing(null)}
/>
) : (
<button
<button type="button"
onClick={() =>
setEditing({
initial: null,
@@ -675,7 +675,7 @@ export default function AdminStoragePanel(): React.ReactElement {
</Section>
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
@@ -715,21 +715,21 @@ export default function AdminStoragePanel(): React.ReactElement {
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
@@ -746,7 +746,7 @@ export default function AdminStoragePanel(): React.ReactElement {
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
@@ -236,7 +236,7 @@ export default function BackendForm({
)}
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={apply}
disabled={!canApply}
style={{
@@ -248,7 +248,7 @@ export default function BackendForm({
>
{t('storage.form.apply')}
</button>
<button
<button type="button"
onClick={onCancel}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
@@ -163,7 +163,7 @@ export default function BackgroundTasksWidget() {
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<div>
<button
<button type="button"
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
@@ -192,7 +192,7 @@ export default function BackgroundTasksWidget() {
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button
<button type="button"
onClick={() => retryWithAi(task)}
disabled={retrying === task.id}
className="bg-surface-tertiary text-content"
@@ -211,7 +211,7 @@ export default function BackgroundTasksWidget() {
</div>
{task.status !== 'running' && (
<button
<button type="button"
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
@@ -48,12 +48,12 @@ export function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
const [r, g, b] = m.map(x => Number.parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
if (v == null || isNaN(v)) return '-'
if (v == null || Number.isNaN(v)) return '-'
const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
}
@@ -75,7 +75,8 @@ export function splitColorFor(userId: number, order: number) {
}
export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
const id = Number.isFinite(userId) ? Math.trunc(userId) : 0
return SPLIT_COLORS[(id - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
/**
@@ -32,7 +32,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
@@ -40,7 +40,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText(/Create categories and entries/i);
expect(await screen.findByText(/Create categories and entries/i)).toBeInTheDocument();
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
@@ -48,7 +48,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
@@ -57,7 +57,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel Paris');
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
@@ -87,7 +87,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Budget');
expect(await screen.findByText('Budget')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
@@ -96,7 +96,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
expect(await screen.findByText('CSV')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
@@ -105,7 +105,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
expect(await screen.findByPlaceholderText('New Entry')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
@@ -124,7 +124,7 @@ describe('BudgetPanel', () => {
await user.type(nameInput, 'Restaurant Dinner');
const addBtn = screen.getByTitle('Add Reservation');
await user.click(addBtn);
await screen.findByText('Restaurant Dinner');
expect(await screen.findByText('Restaurant Dinner')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
@@ -161,7 +161,7 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
expect(await screen.findByText('Hotel B')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
@@ -183,7 +183,7 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
// Component renders even in empty state
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
@@ -193,7 +193,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Misc');
expect(await screen.findByText('Misc')).toBeInTheDocument();
// Row exists - EUR formatting would appear in values
});
@@ -233,7 +233,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Pizza{Enter}');
await screen.findByText('Pizza');
expect(await screen.findByText('Pizza')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
@@ -241,7 +241,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} tripMembers={[]} />);
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
@@ -342,7 +342,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
@@ -356,7 +356,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
const input = await screen.findByPlaceholderText('Enter category name...');
await user.type(input, 'Souvenirs{Enter}');
await screen.findByText('Souvenirs');
expect(await screen.findByText('Souvenirs')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
@@ -371,7 +371,7 @@ describe('BudgetPanel', () => {
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
],
flows: [
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
{ from: { user_id: 1, username: 'alice', avatar_url: null }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 10 },
],
})
)
@@ -387,7 +387,7 @@ describe('BudgetPanel', () => {
await user.click(settlementBtn);
// alice and bob should appear in balances section
await screen.findByText('alice');
await screen.findByText('bob');
expect(await screen.findByText('bob')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
@@ -408,7 +408,7 @@ describe('BudgetPanel', () => {
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Shared Dinner');
await screen.findByText('testuser');
expect(await screen.findByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
@@ -451,7 +451,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
expect(await screen.findByText('2025-06-15')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
@@ -465,7 +465,7 @@ describe('BudgetPanel', () => {
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
flows: [{ from: { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 30 }]
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
+3 -3
View File
@@ -46,7 +46,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
@@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
@@ -101,7 +101,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -19,7 +19,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -57,7 +57,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
<button type="button" onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
@@ -586,7 +586,10 @@ describe('BudgetCategoryTable — member chips', () => {
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
const { setBudgetItemMembers, container } = withMembers()
// The mobile stack under the name and the persons column both carry an editor.
const editors = container.querySelectorAll('td button')
// Assigned chips are buttons of their own, so the editors are the two that
// carry the picker icon.
const editors = Array.from(container.querySelectorAll('td button'))
.filter(b => b.querySelector('.lucide-pencil, .lucide-users'))
fireEvent.click(editors[0])
fireEvent.click(screen.getByText('bob'))
@@ -111,7 +111,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
{canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })}
<button type="button" onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
@@ -123,7 +123,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
<button type="button" onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
@@ -225,11 +225,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
readOnly={!canEdit}
/>
) : (
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
@@ -246,7 +246,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
<td style={{ ...td, textAlign: 'center' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
<button type="button" onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
@@ -23,7 +23,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
const save = () => {
setEditing(false)
let v: string | number | null = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = Number.isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
@@ -45,14 +45,27 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
: (value || '')
// Editable cells are real buttons so they can be tabbed to and pressed. A
// read-only cell renders no control at all rather than a disabled one: it is
// plain text, and a disabled button would be announced as an unavailable
// control that never becomes available. Same split the member chips use.
const cellStyle: React.CSSProperties = {
padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
width: '100%', textAlign: 'left', ...style,
}
const content = display || placeholder || '-'
if (readOnly) return <div style={{ ...cellStyle, cursor: 'default' }}>{content}</div>
return (
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
</div>
<button type="button"
onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ ...cellStyle, cursor: 'pointer' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
{content}
</button>
)
}
@@ -22,6 +22,16 @@ function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
return { onSetMembers, onTogglePaid, ...utils }
}
/**
* The member picker trigger. A chip that can be clicked is a real button too,
* so it is the icon that tells the trigger apart from the chips before it.
*/
function picker(): HTMLElement {
const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users'))
if (!trigger) throw new Error('no picker trigger rendered')
return trigger
}
describe('ChipWithTooltip', () => {
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
@@ -76,7 +86,7 @@ describe('BudgetMemberChips', () => {
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
expect(picker()).toBeInTheDocument()
})
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
@@ -114,9 +124,9 @@ describe('BudgetMemberChips', () => {
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
const rows = screen.getAllByRole('button').slice(1)
const rows = screen.getAllByRole('button').slice(2)
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('ada')
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
@@ -127,52 +137,52 @@ describe('BudgetMemberChips', () => {
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
fireEvent.click(screen.getAllByRole('button')[2])
fireEvent.click(screen.getAllByRole('button')[3])
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
})
it('FE-W4BMC-013: picking an assigned member removes them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
fireEvent.click(screen.getAllByRole('button')[1])
fireEvent.click(screen.getAllByRole('button')[2])
expect(onSetMembers).toHaveBeenCalledWith([])
})
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
setup()
const trigger = screen.getByRole('button')
const trigger = picker()
fireEvent.click(trigger)
fireEvent.mouseDown(screen.getAllByRole('button')[1])
expect(screen.getAllByRole('button')).toHaveLength(3)
fireEvent.mouseDown(screen.getAllByRole('button')[2])
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.mouseDown(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.mouseDown(document.body)
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
setup()
const trigger = screen.getByRole('button')
const trigger = picker()
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
setup({ compact: false })
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
expect(picker().style.width).toBe('28px')
})
})
@@ -22,7 +22,7 @@ interface ChipWithTooltipProps {
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const ref = useRef<HTMLElement | null>(null)
const onEnter = () => {
if (ref.current) {
@@ -35,22 +35,34 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
// Identical for both shells — a clickable chip is a real button so it can be
// tabbed to and pressed, a decorative one stays a plain div.
const chipStyle = {
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
padding: 0, fontFamily: 'inherit',
}
const inner = avatarUrl
? <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick}
style={{
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
}
</div>
{onClick ? (
<button type="button" ref={el => { ref.current = el }} aria-label={label}
onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick} style={chipStyle}>
{inner}
</button>
) : (
<div ref={el => { ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={chipStyle}>
{inner}
</div>
)}
{hover && createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
@@ -131,7 +143,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
/>
))}
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
<button type="button" ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -149,7 +161,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
{tripMembers.map(tm => {
const isActive = memberIds.includes(tm.id)
return (
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
<button type="button" key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
@@ -163,7 +175,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={tm.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: tm.username?.[0]?.toUpperCase()
}
</div>
@@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
{avatarUrl ? <img src={avatarUrl} alt={username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
@@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
<button type="button" onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
@@ -83,7 +83,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
{/* Decoration inside the settlement toggle: the hint hangs off
hover, and the click only keeps the toggle from firing. A
role of its own would give the toggle a second name and a
nested control, so it declares itself presentational. */}
<span style={{ display: 'flex', cursor: 'help' }}
role="presentation"
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
onClick={e => e.stopPropagation()}
@@ -22,7 +22,7 @@ export function splitCents(amount: number, n: number): number[] {
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
}
/** True when the payer amounts add up to the expense total, to the cent. */
@@ -45,7 +45,7 @@ export function rebalancePayers(
if (free.length === 0) return amounts
const pinnedSum = all
.filter(id => pinned.has(id))
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
.reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
const shares = splitCents(total - pinnedSum, free.length)
const next = { ...amounts }
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
@@ -153,7 +153,7 @@ export function calculateTicketShares(items: TicketItem[]): { shares: Record<num
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceNum = Number.parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
+26 -26
View File
@@ -348,12 +348,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
<button type="button" onClick={settleAll} disabled={!(settlement?.flows || []).length}
className="bg-surface-card border border-edge text-content disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Check size={16} /> {t('costs.settleUp')}
</button>
<button onClick={() => { setEditing(null); setModalOpen(true) }}
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={16} /> {t('costs.addExpense')}
@@ -401,14 +401,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{filterControls}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
<button type="button" key={f} onClick={() => setFilter(f)}
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)}
</button>
))}
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-input border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -451,7 +451,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)}
<button type="button" onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')}
@@ -560,7 +560,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span>
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
{canEdit && <button type="button" onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
</div>
</div>
))}
@@ -589,7 +589,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div>
{canEdit && (
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
@@ -628,7 +628,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
<button type="button" onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div>
{SettleFlows()}
@@ -638,7 +638,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-card border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -650,7 +650,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
<button type="button" key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
@@ -705,8 +705,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
return (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
<button type="button" title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button type="button" title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)
}
@@ -839,8 +839,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
<button type="button" title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button type="button" title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
@@ -970,7 +970,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [saving, setSaving] = useState(false)
const amt = parseFloat(amount) || 0
const amt = Number.parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
@@ -991,8 +991,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -1116,8 +1116,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
@@ -1130,13 +1130,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
const valid = name.trim().length > 0 && payersOk && (
isTicketMode
@@ -1263,7 +1263,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// dropping the payer would store the entry with a total of 0.
const payerList = multiPayer
? [...payerIds]
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
// A receipt line can name somebody who is not ticked as a participant. Sending
@@ -1275,7 +1275,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const memberList = memberIds.map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
? (Number.parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
@@ -1315,8 +1315,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div>
}>
{/* Two columns on desktop: what the expense was on the left, how it is
@@ -1370,7 +1370,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon; const on = cat === c.key
return (
<button key={c.key} onClick={() => setCat(c.key)}
<button type="button" key={c.key} onClick={() => setCat(c.key)}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
@@ -53,7 +53,7 @@ describe('CollabChat', () => {
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
expect(await screen.findByText('Start the conversation')).toBeInTheDocument();
});
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
@@ -85,7 +85,7 @@ describe('CollabChat', () => {
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
expect(await screen.findByText('Hello world!')).toBeInTheDocument();
});
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
@@ -599,7 +599,7 @@ describe('CollabChat', () => {
await screen.findByText('New 100');
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
await user.click(loadMoreBtn);
await screen.findByText('Older message');
expect(await screen.findByText('Older message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
+3 -3
View File
@@ -39,7 +39,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
<button type="button" onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
@@ -51,7 +51,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
<button type="button" ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -80,7 +80,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -46,7 +46,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<button key={c} onClick={() => setCat(c)} style={{
<button type="button" key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
@@ -58,7 +58,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
<button type="button" key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
@@ -24,7 +24,7 @@ export function ChatMessages(props: any) {
}}>
{hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{
<button type="button" onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
@@ -181,7 +181,7 @@ export function ChatMessages(props: any) {
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
<button type="button" onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -193,7 +193,7 @@ export function ChatMessages(props: any) {
<Reply size={11} />
</button>
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
<button type="button" onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -18,7 +18,7 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
return (
<>
<button ref={ref} onClick={onReact}
<button type="button" ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
@@ -31,7 +31,7 @@ export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
<button type="button" key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
@@ -47,7 +47,7 @@ describe('CollabNotes', () => {
it('FE-COMP-NOTES-002: shows empty state when no notes', async () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-COMP-NOTES-003: shows New Note button', async () => {
@@ -70,7 +70,7 @@ describe('CollabNotes', () => {
)
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Packing Tips');
expect(await screen.findByText('Packing Tips')).toBeInTheDocument();
});
it('FE-COMP-NOTES-005: clicking New Note opens modal', async () => {
@@ -79,7 +79,7 @@ describe('CollabNotes', () => {
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
// Modal opens with a title input — placeholder is "Note title" (no ellipsis)
await screen.findByPlaceholderText('Note title');
expect(await screen.findByPlaceholderText('Note title')).toBeInTheDocument();
});
it('FE-COMP-NOTES-006: note title is shown in the grid', async () => {
@@ -96,7 +96,7 @@ describe('CollabNotes', () => {
)
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('My Checklist');
expect(await screen.findByText('My Checklist')).toBeInTheDocument();
});
it('FE-COMP-NOTES-007: multiple notes all render', async () => {
@@ -117,8 +117,11 @@ describe('CollabNotes', () => {
it('FE-COMP-NOTES-008: Notes title heading is shown', async () => {
render(<CollabNotes {...defaultProps} />);
// The loading panel renders its own copy of the heading and swaps the whole
// node out once the fetch settles, so wait for the loaded state first.
await screen.findByText('No notes yet');
// collab.notes.title = "Notes"
await screen.findByText('Notes');
expect(screen.getByText('Notes')).toBeInTheDocument();
});
it('FE-COMP-NOTES-009: create note calls POST API', async () => {
@@ -236,7 +239,7 @@ describe('CollabNotes', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Editable Note');
await user.click(screen.getByTitle('Edit'));
await screen.findByDisplayValue('Editable Note');
expect(await screen.findByDisplayValue('Editable Note')).toBeInTheDocument();
});
it('FE-COMP-NOTES-016: category filter hides notes from other categories', async () => {
@@ -280,7 +283,7 @@ describe('CollabNotes', () => {
},
});
});
await screen.findByText('Live Note');
expect(await screen.findByText('Live Note')).toBeInTheDocument();
});
it('FE-COMP-NOTES-018: WebSocket collab:note:deleted event removes note', async () => {
@@ -403,7 +406,7 @@ describe('CollabNotes', () => {
await screen.findByText('No notes yet');
await user.click(screen.getByTitle('Manage Categories'));
// The modal header renders "Category Settings" or similar
await screen.findByText('Manage Categories', { selector: 'h3' });
expect(await screen.findByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
});
it('FE-COMP-NOTES-025: CategorySettingsModal shows no categories message when empty', async () => {
@@ -411,7 +414,7 @@ describe('CollabNotes', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('No categories yet');
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-NOTES-026: CategorySettingsModal add new category', async () => {
@@ -426,7 +429,7 @@ describe('CollabNotes', () => {
const addBtn = newCatInput.nextElementSibling as HTMLElement;
await user.click(addBtn);
// "Transport" category appears in the modal
await screen.findByText('Transport');
expect(await screen.findByText('Transport')).toBeInTheDocument();
});
it('FE-COMP-NOTES-027: CategorySettingsModal close button dismisses it', async () => {
@@ -674,7 +677,7 @@ describe('CollabNotes', () => {
// Click the PDF badge to open FilePreviewPortal
await user.click(screen.getByText('PDF'));
// FilePreviewPortal renders the file name in the header
await screen.findByText('document.pdf');
expect(await screen.findByText('document.pdf')).toBeInTheDocument();
});
it('FE-COMP-NOTES-037: note with website shows website thumbnail component', async () => {
@@ -855,7 +858,7 @@ describe('CollabNotes', () => {
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
// Find the "Transport" category name span and click to edit
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
if (categoryNameSpan) {
await user.click(categoryNameSpan);
// Now an input with value "Transport" should appear
@@ -977,7 +980,7 @@ describe('CollabNotes', () => {
await screen.findByText('Manage Categories', { selector: 'h3' });
// Find and click the "OldCat" category name span to enter edit mode
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
if (oldCatSpan) {
await user.click(oldCatSpan);
const editInput = screen.getByDisplayValue('OldCat');
@@ -1348,7 +1351,7 @@ describe('CollabNotes details', () => {
it('FE-W5CNT-001: a corrupt category cache in localStorage is ignored', async () => {
localStorage.setItem('collab-cats-1', '{not json');
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-002: a category without a stored colour falls back to the first palette entry', async () => {
@@ -1369,13 +1372,13 @@ describe('CollabNotes details', () => {
it('FE-W5CNT-004: notes served as a bare array are rendered', async () => {
serveNotes([buildNote({ title: 'Array note' })]);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Array note');
expect(await screen.findByText('Array note')).toBeInTheDocument();
});
it('FE-W5CNT-005: an empty payload yields an empty list', async () => {
serveNotes(null);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-006: a failing load falls back to the empty state', async () => {
@@ -1383,7 +1386,7 @@ describe('CollabNotes details', () => {
http.get('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-007: a WebSocket create for a note already in the list does not duplicate it', async () => {
@@ -1434,7 +1437,7 @@ describe('CollabNotes details', () => {
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('Fresh note');
expect(await screen.findByText('Fresh note')).toBeInTheDocument();
});
it('FE-W5CNT-011: an empty create response leaves the list untouched', async () => {
@@ -1764,10 +1767,13 @@ describe('CollabNotes details', () => {
if (!img) throw new Error('image attachment not rendered yet');
return img;
});
fireEvent.mouseEnter(image);
expect(image.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(image);
expect(image.style.transform).toBe('scale(1)');
// The clickable thumbnail is a real button wrapped around the image, so the
// hover transform lands on that button.
const imageTile = image.closest('button') as HTMLElement;
fireEvent.mouseEnter(imageTile);
expect(imageTile.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(imageTile);
expect(imageTile.style.transform).toBe('scale(1)');
fireEvent.click(image);
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
});
@@ -1838,7 +1844,7 @@ describe('CollabNotes details', () => {
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
@@ -1891,7 +1897,7 @@ describe('CollabNotes details', () => {
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
+11 -10
View File
@@ -303,13 +303,13 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
{canEdit && <button type="button" onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
{canEdit && <button type="button" onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
@@ -322,7 +322,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
<button
<button type="button"
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
@@ -335,7 +335,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
<button type="button"
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
@@ -403,7 +403,8 @@ function ViewNoteModal(S: NotesState) {
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
role="presentation"
onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }}
>
<div
style={{
@@ -412,7 +413,6 @@ function ViewNoteModal(S: NotesState) {
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
@@ -430,13 +430,13 @@ function ViewNoteModal(S: NotesState) {
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
{canEdit && <button type="button" onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
<button type="button" onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -462,17 +462,18 @@ function ViewNoteModal(S: NotesState) {
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
<button type="button" title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
border: 'none', padding: 0, fontFamily: 'inherit',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
</button>
)}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
@@ -1,10 +1,22 @@
import { useState, useEffect } from 'react'
import { getAuthUrl } from '../../api/authUrl'
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLElement>; onMouseLeave?: React.MouseEventHandler<HTMLElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
if (!authSrc) return null
// A clickable thumbnail gets a real button around the image so it is
// reachable by keyboard. The caller's style stays on the outer box, so the
// hover transform/transition still animates the element that carries them.
if (onClick) {
return (
<button type="button" onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
style={{ ...style, padding: 0, border: 'none', background: 'none', display: 'block' }}>
<img src={authSrc} alt={alt} style={{ ...style, width: '100%', height: '100%', display: 'block' }} />
</button>
)
}
return <img src={authSrc} alt={alt} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />
}
@@ -80,26 +80,26 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
display: 'flex', gap: 2,
}}>
{note.content && (
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
<button type="button" onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Maximize2 size={10} />
</button>
)}
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
{canEdit && <button type="button" onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
</button>}
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
{canEdit && <button type="button" onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={10} />
</button>}
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
{canEdit && <button type="button" onClick={handleDelete} title={t('collab.notes.delete')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -171,17 +171,18 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
<button type="button" key={a.id} title={a.original_name} aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
style={{
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
border: 'none', padding: 0, fontFamily: 'inherit',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
</button>
)
})}
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
@@ -69,17 +69,17 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
}} onClick={onClose}>
}} role="presentation" onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
}} onClick={e => e.stopPropagation()}>
}} role="presentation" onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'}
</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
<X size={16} />
</button>
</div>
@@ -96,7 +96,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Color swatches */}
<div style={{ display: 'flex', gap: 4 }}>
{NOTE_COLORS.map(c => (
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
<button type="button" key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1, transition: 'transform 0.1s',
@@ -107,7 +107,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Category name — editable */}
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
{/* Delete */}
<button onClick={() => handleRemoveCategory(cat)} style={{
<button type="button" onClick={() => handleRemoveCategory(cat)} style={{
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
@@ -126,7 +126,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
<button type="button" onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', flexShrink: 0,
@@ -136,7 +136,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
</div>
{/* Save */}
<button onClick={handleSave} style={{
<button type="button" onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}>
@@ -25,10 +25,10 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
}
return (
<span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0', background: 'none', border: 'none', textAlign: 'left', fontFamily: 'inherit' }}
title="Click to rename">
{name}
</span>
</button>
)
}
@@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const openInNewTab = () => openFile(rawUrl).catch(() => {})
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} role="presentation" onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} role="presentation" onClick={e => e.stopPropagation()}>
{authUrl
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
@@ -39,30 +39,30 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
</div>
</div>
</div>
) : (
/* Document viewer — card with header */
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} role="presentation" onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
</p>
</object>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
</div>
)}
</div>
@@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
}}
>
<form
role="presentation"
style={{
background: 'var(--bg-card)',
borderRadius: 16,
+1 -1
View File
@@ -170,7 +170,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
@@ -55,7 +55,7 @@ beforeEach(() => {
describe('CollabPolls', () => {
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
@@ -79,7 +79,7 @@ describe('CollabPolls', () => {
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
expect(await screen.findByText('Best destination?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-004: renders poll options', async () => {
@@ -108,7 +108,7 @@ describe('CollabPolls', () => {
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Modal has a question placeholder input
await screen.findByPlaceholderText(/what should we do/i);
expect(await screen.findByPlaceholderText(/what should we do/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
@@ -153,7 +153,7 @@ describe('CollabPolls', () => {
await user.type(optionInputs[1], 'Japanese');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await screen.findByText('Where to eat?');
expect(await screen.findByText('Where to eat?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
@@ -188,7 +188,7 @@ describe('CollabPolls', () => {
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/closed/i);
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
@@ -236,7 +236,7 @@ describe('CollabPolls', () => {
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
expect(await screen.findByText('Live poll?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
@@ -337,7 +337,7 @@ describe('CollabPolls details', () => {
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
servePolls([buildPoll({ question: 'Array shaped?' })]);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Array shaped?');
expect(await screen.findByText('Array shaped?')).toBeInTheDocument();
});
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
@@ -484,13 +484,13 @@ describe('CollabPolls details', () => {
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
servePolls({});
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
+10 -10
View File
@@ -88,8 +88,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
}
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
@@ -122,13 +122,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Multi choice toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<div onClick={() => setMultiChoice(!multiChoice)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer', border: 'none',
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
display: 'flex', alignItems: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
</div>
</button>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label>
@@ -174,7 +174,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
}}>
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
</div>
{hover && createPortal(
<div style={{
@@ -250,14 +250,14 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{canEdit && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
<button type="button" onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Lock size={12} />
</button>
)}
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
<button type="button" onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -278,7 +278,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
return (
// React dispatches no mouse events on a disabled control, so the
// handlers below need no isClosed guard of their own.
<button key={idx} onClick={() => onVote(poll.id, idx)}
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
@@ -465,7 +465,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{t('collab.polls.title')}
</h3>
{canEdit && (
<button onClick={() => setShowForm(true)} style={{
<button type="button" onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
@@ -179,7 +179,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
overflow: 'hidden', flexShrink: 0,
}}>
{p.avatar
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={avatarSrc(p.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: p.username?.[0]?.toUpperCase()
}
</div>
@@ -97,8 +97,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const cleanName = name.trim()
if (!cleanName) return
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
const latNum = lat.trim() ? Number(lat) : NaN
const lngNum = lng.trim() ? Number(lng) : NaN
const latNum = lat.trim() ? Number(lat) : Number.NaN
const lngNum = lng.trim() ? Number(lng) : Number.NaN
setSaving(true)
try {
const res = await collectionsApi.savePlace({
@@ -135,7 +135,10 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
// Same pairs as before, written so no two quantifiers can claim the same
// character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous,
// which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab.
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
@@ -152,15 +152,15 @@ export default function CollectionPlaceDetail({
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
const latNum = lat.trim() ? Number(lat) : NaN
const lngNum = lng.trim() ? Number(lng) : NaN
const latNum = lat.trim() ? Number(lat) : Number.NaN
const lngNum = lng.trim() ? Number(lng) : Number.NaN
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
@@ -176,7 +176,7 @@ export default function CollectionPlaceDetail({
const CatIcon = getCategoryIcon(place.category?.icon)
return (
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} role="presentation" onClick={e => e.stopPropagation()}>
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
@@ -51,7 +51,7 @@ export default function StatusBadge({ status, onChange, showLabel = true, size =
return <span className={className} title={label}>{content}</span>
}
// Rendered as a role=button span (not a native <button>) on purpose: inside
// Rendered as a role=button span (not a native <button type="button">) on purpose: inside
// the .trek-dash scope the global `.trek-dash button` reset would strip the
// pill's background/border/padding, and the pill also has to sit inside the
// grid card's own clickable element without nesting one button in another.
@@ -38,7 +38,7 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
<div className="tool">
<div className="tool-head">
<div className="tool-title"><Bookmark size={14} /> {t('collections.widget.title')}</div>
<button className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
<button type="button" className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
<ArrowRight size={14} />
</button>
</div>
@@ -47,7 +47,7 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
) : (
<div className="col-badges">
{lists.slice(0, 6).map(list => (
<button
<button type="button"
key={list.id}
className="col-badge"
style={{ ['--badge-color' as string]: list.color || '#6366f1' }}
@@ -176,7 +176,7 @@ describe('FileManager', () => {
await user.click(trashBtn);
// Trashed file should appear
await screen.findByText('old.pdf');
expect(await screen.findByText('old.pdf')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
@@ -407,7 +407,7 @@ describe('FileManager', () => {
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Eiffel Tower');
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
@@ -445,7 +445,7 @@ describe('FileManager', () => {
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Hotel Paris');
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
@@ -492,7 +492,7 @@ describe('FileManager', () => {
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Notre Dame');
await screen.findByText('Airbnb');
expect(await screen.findByText('Airbnb')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
@@ -543,7 +543,7 @@ describe('FileManager', () => {
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Arc de Triomphe');
expect(await screen.findByText('Arc de Triomphe')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
@@ -554,7 +554,7 @@ describe('FileManager', () => {
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
// Source badge text includes place name
await screen.findByText(/Colosseum/);
expect(await screen.findByText(/Colosseum/)).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
@@ -9,9 +9,9 @@ import { transportIcon } from './FileManager.helpers'
export function AssignModal(S: FileManagerState) {
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setAssignFileId(null)}>
<div style={{
<div role="presentation" style={{
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
@@ -22,7 +22,7 @@ export function AssignModal(S: FileManagerState) {
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<button type="button" onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<X size={18} />
</button>
</div>
@@ -67,7 +67,7 @@ export function AssignModal(S: FileManagerState) {
const placeBtn = (p: Place, idx: number) => {
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
return (
<button key={`${p.id}-${idx}`} onClick={async () => {
<button type="button" key={`${p.id}-${idx}`} onClick={async () => {
if (isLinked) {
if (file.place_id === p.id) {
await handleAssign(file.id, { place_id: null })
@@ -123,13 +123,13 @@ export function AssignModal(S: FileManagerState) {
) : null
})()}
</div>
{dayPlaces.map(placeBtn)}
{dayPlaces.map((p, i) => placeBtn(p, i))}
</div>
))}
{unassigned.length > 0 && (
<div>
{dayGroups.length > 0 && <div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
{unassigned.map(placeBtn)}
{unassigned.map((p, i) => placeBtn(p, i))}
</div>
)}
</div>
@@ -142,7 +142,7 @@ export function AssignModal(S: FileManagerState) {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
<button type="button" key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
@@ -24,7 +24,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
cursor: 'default',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: name?.[0]?.toUpperCase()
}
</div>
@@ -51,7 +51,7 @@ export function FilesView(S: FileManagerState) {
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
<button type="button" key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
@@ -53,7 +53,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
const hasPrev = index > 0
const hasNext = index < files.length - 1
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
<button onClick={e => { e.stopPropagation(); onClick() }}
<button type="button" onClick={e => { e.stopPropagation(); onClick() }}
style={{
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
@@ -68,8 +68,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
return (
<div
// Backdrop only — Escape and the header's close button do the same job for
// the keyboard. Closing on the backdrop's own clicks (rather than letting
// every child stop the bubble) keeps the chrome free of click handlers.
role="presentation"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
if (touchStart === null) return
@@ -80,47 +84,47 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
<button type="button"
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button
<button type="button"
onClick={() => triggerDownload(file.url, file.original_name)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.download') || 'Download'}>
<Download size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
</div>
</div>
{/* Main image + nav */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
<div role="presentation" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
{navBtn('left', goPrev, hasPrev)}
{fileIsVideo ? (
<div onClick={e => e.stopPropagation()}>
<div>
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
</div>
) : (
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
)}
{navBtn('right', goNext, hasNext)}
</div>
{/* Thumbnail strip */}
{files.length > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }}>
{files.map((f, i) => (
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
))}
@@ -156,7 +160,7 @@ function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string };
}, [file.url, fileIsVideo, visible])
return (
<button ref={ref} onClick={onClick} style={{
<button type="button" ref={ref} onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
@@ -34,27 +34,29 @@ export function MarkdownPreviewModal(S: FileManagerState) {
return createPortal(
<div
role="presentation"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setPreviewFile(null)}
>
<div
role="presentation"
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
<button type="button"
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
<button type="button"
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button onClick={() => setPreviewFile(null)}
<button type="button" onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
<X size={18} />
</button>
@@ -8,31 +8,33 @@ export function PdfPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
return createPortal(
<div
role="presentation"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setPreviewFile(null)}
>
<div
role="presentation"
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
<button type="button"
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
<button type="button"
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button onClick={() => setPreviewFile(null)}
<button type="button" onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -47,7 +49,7 @@ export function PdfPreviewModal(S: FileManagerState) {
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
<button type="button" onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p>
</object>
</div>
+22 -17
View File
@@ -34,10 +34,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
className="group"
>
{/* Icon or thumbnail */}
<div
<button
type="button"
disabled={isTrash}
aria-label={file.original_name}
onClick={() => !isTrash && openFile(file)}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
flexShrink: 0, width: 36, height: 36, borderRadius: 8, padding: 0,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
}}
@@ -48,13 +51,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
const isPdf = file.mime_type === 'application/pdf'
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
</span>
)
})()
}
</div>
</button>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
@@ -63,12 +66,14 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
)}
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span
<button
type="button"
disabled={isTrash}
onClick={() => !isTrash && openFile(file)}
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer', textAlign: 'left', minWidth: 0 }}
>
{file.original_name}
</span>
</button>
</div>
{file.description && (
@@ -76,7 +81,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
)}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
{!!file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlaces.map(p => (
@@ -87,7 +92,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
{!!file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)}
</div>
@@ -97,34 +102,34 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_delete', trip) && <button type="button" onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>}
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_delete', trip) && <button type="button" onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
</>
) : (
<>
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
<button type="button" onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_edit', trip) && <button type="button" onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
<button type="button" onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
<button type="button" onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Download size={14} />
</button>
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_delete', trip) && <button type="button" onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
@@ -36,7 +36,7 @@ export function FileManagerToolbar(S: FileManagerState) {
: tab.id === 'collab' ? files.filter(f => f.note_id).length
: 0
return (
<button key={tab.id} onClick={() => setFilterType(tab.id)}
<button type="button" key={tab.id} onClick={() => setFilterType(tab.id)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -63,7 +63,7 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
<button onClick={toggleTrash} style={{
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
@@ -8,7 +8,7 @@ export function TrashView(S: FileManagerState) {
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{trashFiles.length > 0 && can('file_delete', trip) && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{
<button type="button" onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
@@ -49,7 +49,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -72,10 +72,10 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<div
<button type="button"
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
@@ -93,7 +93,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
<Check size={12} />
</div>
)}
</div>
</button>
))}
</div>
@@ -102,7 +102,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
<button
<button type="button"
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
@@ -119,10 +119,10 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={handleInvite}
disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
@@ -47,7 +47,7 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.trips.linkTrip')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -81,7 +81,7 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
</div>
)}
</div>
<button
<button type="button"
onClick={() => handleAdd(trip.id)}
disabled={adding === trip.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -54,7 +55,9 @@ export function DatePicker({ value, onChange, tripDates }: {
{open && (
<>
<div className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Month nav */}
<div className="flex items-center justify-between mb-2">
@@ -81,7 +84,7 @@ export function DatePicker({ value, onChange, tripDates }: {
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === new Date().toISOString().split('T')[0]
const isToday = dateStr === localIsoDate()
return (
<button
@@ -78,7 +78,8 @@ describe('EntryCard', () => {
const user = userEvent.setup()
const { container, onEdit } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
await user.click(container.querySelectorAll('button')[0])
// The photo itself is a button now, so pick the menu trigger by its own styling.
await user.click(container.querySelector('button[class*="bg-black/40"]') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(onEdit).toHaveBeenCalledTimes(1)
@@ -110,7 +111,10 @@ describe('EntryCard', () => {
it('FE-JRN-CARD-007: hides the menu entirely in read-only mode', () => {
const { container } = mountCard(buildEntry({ photos: [buildPhoto(100)] }), true)
expect(container.querySelectorAll('button')).toHaveLength(0)
// Only the photo remains clickable; the menu trigger is gone.
const buttons = container.querySelectorAll('button')
expect(buttons).toHaveLength(1)
expect(buttons[0].querySelector('img')).toBeInTheDocument()
})
it('FE-JRN-CARD-008: renders mood, weather and tags in the meta row', () => {
@@ -73,15 +73,15 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{/* Menu top-right */}
{!readOnly && (
<div className="absolute top-2.5 right-3 z-[2]">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<button type="button" ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<MoreHorizontal size={14} />
</button>
{menuOpen && createPortal(
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>,
document.body,
@@ -113,15 +113,15 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
</div>
{!readOnly && (
<div className="relative">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<MoreHorizontal size={14} />
</button>
{menuOpen && createPortal(
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>,
document.body,
@@ -185,6 +185,9 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
return (
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
className={`rounded-[18px] px-3.5 py-3 flex items-center gap-3 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:-translate-y-0.5 cursor-pointer' : ''}`}
style={{ border: '1.5px dashed var(--vg-line2)', background: 'var(--vg-surf2)' }}
>
@@ -212,6 +215,9 @@ export function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?:
return (
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
>
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
@@ -2,6 +2,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { localIsoDate } from '../../utils/localDate'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
@@ -516,7 +517,7 @@ describe('EntryEditor', () => {
entry_date: '', photos: undefined as unknown as JourneyPhoto[],
}))
const today = new Date().toISOString().split('T')[0]
const today = localIsoDate() // local, matching the editor's default — not the UTC date
const label = new Date(today + 'T00:00:00').toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -33,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const toast = useToast()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate())
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
@@ -78,7 +79,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const isDirty = (
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
entryDate !== (entry.entry_date || localIsoDate()) ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
@@ -208,7 +209,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setPendingFiles(prev => [...prev, ...normalized])
}
const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN })
const contextLocation = isValidGeoPoint({ lat: locationLat ?? Number.NaN, lng: locationLng ?? Number.NaN })
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
: null
@@ -255,7 +256,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
<button onClick={handleClose} className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" onClick={handleClose} className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -274,7 +275,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button
<button type="button"
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
disabled={saving}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
@@ -286,7 +287,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
)}
</button>
{galleryPhotos.length > 0 && (
<button
<button type="button"
onClick={() => { setPhotoTab('gallery'); setShowGalleryPick(!showGalleryPick) }}
className={`flex-1 border rounded-xl py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
showGalleryPick
@@ -297,7 +298,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
<button
<button type="button"
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
disabled={saving}
aria-label={t('journey.photo.add')}
@@ -306,7 +307,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
>
<Camera size={14} />
</button>
<button
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
@@ -327,8 +328,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{availableGalleryPhotos.map(gp => (
<div
<button
type="button"
key={gp.id}
aria-label={t('journey.editor.fromGallery')}
onClick={async () => {
if (entry.id > 0) {
try {
@@ -340,11 +343,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setPhotos(prev => [...prev, gp])
}
}}
className="relative w-full rounded-xl overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
</button>
))}
{availableGalleryPhotos.length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
@@ -366,7 +369,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</p>
</div>
{pendingProviderGroups.length > 0 && (
<button onClick={() => setPendingProviderGroups([])} className="text-[10px] text-zinc-500 hover:text-zinc-900 dark:hover:text-white whitespace-nowrap">
<button type="button" onClick={() => setPendingProviderGroups([])} className="text-[10px] text-zinc-500 hover:text-zinc-900 dark:hover:text-white whitespace-nowrap">
{pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)} {t('journey.editor.externalPhotosQueued') || 'queued'} · {t('common.clear') || 'Clear'}
</button>
)}
@@ -379,7 +382,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="h-full min-h-0 flex flex-col">
<div className="flex gap-1 px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 overflow-x-auto">
{availableProviders.map(provider => (
<button
<button type="button"
key={provider.id}
data-testid={`journey-external-provider-${provider.id}`}
onClick={() => setExternalProvider(provider.id)}
@@ -441,7 +444,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
{idx > 0 && photos.length > 1 && (
<button
<button type="button"
onClick={e => {
e.stopPropagation()
const prevOrder = photos
@@ -469,7 +472,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{t('journey.editor.makeFirst')}
</button>
)}
<button
<button type="button"
onClick={async (e) => {
e.stopPropagation()
setPhotos(prev => prev.filter(x => x.id !== p.id))
@@ -489,7 +492,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
<button
<button type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
@@ -543,13 +546,13 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
/>
{pros.length > 1 && (
<button onClick={() => setPros(pros.filter((_, j) => j !== i))} className="p-1 text-green-300 dark:text-green-700 hover:text-green-600 dark:hover:text-green-400 flex-shrink-0">
<button type="button" onClick={() => setPros(pros.filter((_, j) => j !== i))} className="p-1 text-green-300 dark:text-green-700 hover:text-green-600 dark:hover:text-green-400 flex-shrink-0">
<X size={13} strokeWidth={2.5} />
</button>
)}
</div>
))}
<button
<button type="button"
onClick={() => setPros([...pros, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
>
@@ -577,13 +580,13 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
/>
{cons.length > 1 && (
<button onClick={() => setCons(cons.filter((_, j) => j !== i))} className="p-1 text-red-300 dark:text-red-700 hover:text-red-600 dark:hover:text-red-400 flex-shrink-0">
<button type="button" onClick={() => setCons(cons.filter((_, j) => j !== i))} className="p-1 text-red-300 dark:text-red-700 hover:text-red-600 dark:hover:text-red-400 flex-shrink-0">
<X size={13} strokeWidth={2.5} />
</button>
)}
</div>
))}
<button
<button type="button"
onClick={() => setCons([...cons, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
@@ -657,10 +660,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
{showLocationResults && locationResults.length > 0 && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
<div role="presentation" className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg overflow-hidden max-h-[240px] overflow-y-auto">
{locationResults.map((r, i) => (
<button
<button type="button"
key={i}
onClick={() => {
setLocationName(r.name)
@@ -697,7 +700,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const Icon = config.icon
const active = mood === key
return (
<button key={key} onClick={() => setMood(active ? '' : key)}
<button type="button" key={key} onClick={() => setMood(active ? '' : key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-all ${
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
}`}
@@ -717,7 +720,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const Icon = config.icon
const active = weather === key
return (
<button key={key} onClick={() => setWeather(active ? '' : key)}
<button type="button" key={key} onClick={() => setWeather(active ? '' : key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-all ${
active ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}>
@@ -734,8 +737,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
<button onClick={handleClose} className="px-4 h-10 flex items-center rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving} className="px-5 h-10 flex items-center rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 transition-colors">
<button type="button" onClick={handleClose} className="px-4 h-10 flex items-center rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
<button type="button" onClick={handleSave} disabled={saving} className="px-5 h-10 flex items-center rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 transition-colors">
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState, useRef, type HTMLAttributes } from 'react'
import { ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import JournalBody from './JournalBody'
@@ -23,11 +23,24 @@ export function ExpandableStory({ story }: { story: string }) {
}
})
// The text block toggles the same clamp as the Show more / Show less buttons
// below it. It only takes focus while there is something to toggle, so a
// short story stays plain text instead of an empty stop in the tab order.
const toggle: HTMLAttributes<HTMLDivElement> = clamped || expanded
? {
role: 'button',
tabIndex: 0,
'aria-expanded': expanded,
onClick: () => setExpanded(e => !e),
onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(v => !v) } },
}
: {}
return (
<div>
<div
ref={ref}
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
{...toggle}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
@@ -35,7 +48,7 @@ export function ExpandableStory({ story }: { story: string }) {
<JournalBody text={story} />
</div>
{clamped && !expanded && (
<button
<button type="button"
onClick={() => setExpanded(true)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
@@ -43,7 +56,7 @@ export function ExpandableStory({ story }: { story: string }) {
</button>
)}
{expanded && (
<button
<button type="button"
onClick={() => setExpanded(false)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
@@ -44,10 +44,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
try {
const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch {}
} catch { }
}
setAvailableProviders(connected)
} catch {}
} catch { }
})()
}, [])
@@ -133,7 +133,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
</span>
<div className="flex items-center gap-2">
{availableProviders.map(p => (
<button
<button type="button"
key={p.id}
onClick={() => browseProvider(p.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -152,12 +152,16 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
{shownPhotos.map((photo, i) => (
<div
key={photo.id}
role="button"
tabIndex={0}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
>
{photo.media_type === 'video' && !photo.thumbnail_path ? (
// Poster-less video (capture failed / unsupported codec): show a
// neutral tile rather than a broken 404 thumbnail (#823).
{photo.media_type === 'video' &&
photo.provider === 'local' &&
!photo.thumbnail_path ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
<img
@@ -176,7 +180,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
</div>
)}
{/* Delete button */}
<button
<button type="button"
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
@@ -125,7 +125,7 @@ describe('JourneyDetailPage MapView', () => {
it('FE-COMP-JMAPVIEW-005: each row is numbered by its position across the whole journey', () => {
renderMapView()
const berlin = screen.getByText('Museumsinsel').closest('div[class*="cursor-pointer"]')!
const berlin = screen.getByText('Museumsinsel').closest('button[class*="cursor-pointer"]')!
expect(within(berlin as HTMLElement).getByText('3')).toBeInTheDocument()
})
@@ -161,9 +161,9 @@ describe('JourneyDetailPage MapView', () => {
renderMapView({ activeLocationId: '2' })
expect(lastMapProps.current.activeMarkerId).toBe('2')
const row = screen.getByText('Sacré-Cœur').closest('div[class*="cursor-pointer"]') as HTMLElement
const row = screen.getByText('Sacré-Cœur').closest('button[class*="cursor-pointer"]') as HTMLElement
expect(row.className).toContain('border-zinc-900')
const inactive = screen.getByText('Louvre').closest('div[class*="cursor-pointer"]') as HTMLElement
const inactive = screen.getByText('Louvre').closest('button[class*="cursor-pointer"]') as HTMLElement
expect(inactive.className).toContain('border-zinc-200')
})
@@ -23,7 +23,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
if (!byDate.has(d)) byDate.set(d, [])
byDate.get(d)!.push({ entry: e, globalIdx: i })
})
const dates = [...byDate.keys()].sort()
const dates = [...byDate.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const mapItems = useMemo(() => mapEntries.map(e => ({
id: String(e.id),
@@ -85,9 +85,10 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
return (
<div key={e.id}>
<div
<button
type="button"
onClick={() => onLocationClick(String(e.id))}
className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
className={`w-full text-left flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
isActive
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
@@ -114,7 +115,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
{/* Chevron */}
<ChevronRight size={14} className={`flex-shrink-0 ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`} />
</div>
</button>
{/* Connector line */}
{showConnector && (
@@ -1,18 +1,31 @@
import { Image } from 'lucide-react'
import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail')
const isVideo = photo.media_type === 'video'
return (
<img
src={src}
alt=""
className={className}
<div
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style}
onClick={onClick}
loading="lazy"
/>
>
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="w-11 h-11 rounded-full bg-black/60 backdrop-blur-sm flex items-center justify-center text-white">
<Play size={20} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</div>
)
}
@@ -22,9 +35,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
if (count === 1) {
return (
<div className="overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
<button type="button" className="block w-full overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
<PhotoImg photo={photos[0]} className="w-full h-72 object-cover" />
</div>
</button>
)
}
@@ -32,7 +45,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
return (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<PhotoImg key={p.id} photo={p} className="w-full h-52 object-cover cursor-pointer" onClick={() => onClick(i)} />
<button key={p.id} type="button" className="block w-full h-52 overflow-hidden cursor-pointer" onClick={() => onClick(i)}>
<PhotoImg photo={p} className="w-full h-full object-cover" />
</button>
))}
</div>
)
@@ -40,14 +55,14 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
return (
<div className="overflow-hidden flex" style={{ height: 300, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
<button type="button" className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
<PhotoImg photo={photos[0]} className="w-full h-full object-cover" />
</div>
</button>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
<button type="button" className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
<PhotoImg photo={photos[1]} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
</button>
<button type="button" className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
{count > 3 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
@@ -55,8 +70,8 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
+{count - 3}
</div>
)}
</div>
</button>
</div>
</div>
)
}
}
@@ -144,6 +144,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
className={embedded
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
role="presentation"
onClick={embedded ? undefined : onClose}
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
>
@@ -152,6 +153,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={e => e.stopPropagation()}
>
@@ -160,7 +162,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>}
@@ -176,7 +178,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button
<button type="button"
key={f.id}
onClick={() => setFilter(f.id)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
@@ -233,7 +235,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
<span className="text-zinc-400 text-[12px]">&mdash;</span>
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button onClick={handleCustomSearch}
<button type="button" onClick={handleCustomSearch}
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
{t('journey.picker.search')}
</button>
@@ -243,7 +245,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{filter === 'album' && (
<div className="flex gap-2 overflow-x-auto flex-1">
{albums.map((a: any) => (
<button
<button type="button"
key={a.id}
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
@@ -265,7 +267,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
<button type="button"
onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
@@ -274,9 +276,9 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</button>
{addToOpen && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setAddToOpen(false)} />
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
<button
<button type="button"
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
!targetEntryId
@@ -291,7 +293,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
)}
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
<button
<button type="button"
key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
@@ -316,7 +318,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<button
<button type="button"
onClick={() => {
if (allSelected) {
setSelected(new Map())
@@ -365,10 +367,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
const isSelected = selected.has(asset.id)
const alreadyAdded = existingAssetIds.has(asset.id)
return (
<div
<button
type="button"
key={asset.id}
disabled={alreadyAdded}
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative aspect-square rounded-lg overflow-hidden ${
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
alreadyAdded
? 'opacity-40 cursor-not-allowed'
: isSelected
@@ -402,7 +408,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<p className="text-[8px] text-white truncate">{asset.city}</p>
</div>
)}
</div>
</button>
)
})}
</div>
@@ -421,10 +427,10 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
<div className="flex items-center gap-2">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={() => {
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
@@ -87,12 +87,12 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div role="presentation" className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div role="presentation" className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={handleClose} className="w-8 h-8 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button type="button" onClick={handleClose} className="w-8 h-8 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -105,7 +105,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
<button
<button type="button"
onClick={() => coverRef.current?.click()}
className="w-full h-28 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center gap-2 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 overflow-hidden relative"
>
@@ -156,7 +156,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
<div className="text-[10px] text-zinc-500">{trip.place_count || 0} {t('journey.synced.places')}</div>
</div>
<button
<button type="button"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
className="w-8 h-8 rounded-xl flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
title="Unlink trip"
@@ -166,7 +166,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div>
))}
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
<button
<button type="button"
onClick={() => setShowAddTrip(true)}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
@@ -187,7 +187,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
<span className="shrink-0 rounded-full font-semibold uppercase" style={{ fontSize: 8.5, letterSpacing: '0.05em', padding: '2px 7px', ...(c.role === 'owner' ? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' } : { background: 'color-mix(in srgb, var(--vg-ink3) 14%, transparent)', color: 'var(--vg-ink2)' }) }}>{c.role}</span>
{c.role !== 'owner' && (
<button
<button type="button"
onClick={async () => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
@@ -207,7 +207,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
)}
</div>
))}
<button
<button type="button"
onClick={onOpenInvite}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
@@ -228,7 +228,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
{/* Footer */}
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
<button type="button"
onClick={() => setShowDeleteConfirm(true)}
aria-label={t('journey.settings.delete')}
title={t('journey.settings.delete')}
@@ -237,7 +237,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<Trash2 size={14} />
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
</button>
<button
<button type="button"
onClick={handleArchiveToggle}
disabled={archiving}
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
@@ -247,8 +247,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button onClick={handleClose} className="h-10 px-4 rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-10 px-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 transition-colors">
<button type="button" onClick={handleClose} className="h-10 px-4 rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
<button type="button" onClick={handleSave} disabled={saving || !title.trim()} className="h-10 px-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 transition-colors">
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -278,7 +278,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
{/* Add Trip — sits inside the backdrop, so its clicks have to be kept
from bubbling into the close/discard handler underneath */}
{showAddTrip && (
<div onClick={e => e.stopPropagation()}>
<div role="presentation" onClick={e => e.stopPropagation()}>
<AddTripDialog
journeyId={journey.id}
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
@@ -9,7 +9,7 @@ export function VerdictSection({ pros, cons }: { pros: string[]; cons: string[]
return (
<div className="mt-5">
{/* Header — clickable on mobile */}
<button
<button type="button"
onClick={() => setOpen(o => !o)}
className="md:pointer-events-none w-full flex items-center gap-2.5 mb-3.5 group"
>
+2 -2
View File
@@ -410,7 +410,7 @@ function JourneyMap(
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button
<button type="button"
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
@@ -422,7 +422,7 @@ function JourneyMap(
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button
<button type="button"
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
@@ -36,11 +36,12 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!link) return
const previous = link
const updated = { ...link, [key]: !link[key] }
setLink(updated)
try {
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
} catch { setLink(previous); toast.error(t('journey.share.updateFailed')) }
}
const deleteLink = async () => {
@@ -66,7 +67,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
{!link ? (
<button
<button type="button"
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
@@ -78,13 +79,13 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
<div className="flex items-center gap-2 p-2 rounded-2xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<Link size={13} className="text-zinc-400 flex-shrink-0 ml-1.5" />
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
<button
<button type="button"
onClick={copyLink}
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-semibold hover:bg-zinc-700 dark:hover:bg-zinc-200 transition-colors"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
<button
<button type="button"
onClick={deleteLink}
className="flex-shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
@@ -99,7 +100,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
].map(({ key, label, icon: Icon }) => (
<button
<button type="button"
key={key}
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-[12px] font-medium transition-all ${
@@ -72,7 +72,7 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
return (
<button
<button type="button"
onClick={onClick}
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
isActive
@@ -1,5 +1,5 @@
import {
X, Pencil, Trash2, MapPin, Clock, Camera,
X, Pencil, Trash2, MapPin, Clock, Camera, Play,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
@@ -14,30 +14,37 @@ import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// local because the desktop variant carries raw hex values and no dark-mode
// classes, so the two cannot share one table without changing how this looks.
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'journey.weather.sunny' },
partly: { icon: CloudSun, label: 'journey.weather.partly' },
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
sunny: { icon: Sun, label: 'journey.weather.sunny' },
partly: { icon: CloudSun, label: 'journey.weather.partly' },
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
stormy: { icon: CloudLightning, label: 'journey.weather.stormy' },
cold: { icon: Snowflake, label: 'journey.weather.cold' },
cold: { icon: Snowflake, label: 'journey.weather.cold' },
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
function photoUrl(
p: JourneyPhoto,
size: 'thumbnail' | 'original' = 'original',
builder?: (id: number, size?: 'thumbnail' | 'original') => string
): string {
if (builder) return builder(p.photo_id, size)
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
publicPhotoUrl?: (
photoId: number,
size?: 'thumbnail' | 'original'
) => string
onClose: () => void
onEdit: () => void
onDelete: () => void
@@ -60,7 +67,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<button
<button type="button"
onClick={onClose}
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
@@ -68,14 +75,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
</button>
{!readOnly && (
<div className="flex items-center gap-1.5">
<button
<button type="button"
onClick={() => { onClose(); onEdit(); }}
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<Pencil size={13} />
{t('common.edit')}
</button>
<button
<button type="button"
onClick={() => { onClose(); onDelete(); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
>
@@ -91,35 +98,70 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{/* Hero photo(s) */}
{photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer"
<button
type="button"
aria-label={photos[0].caption || t('journey.photos')}
className={`relative block w-full max-h-[50vh] cursor-pointer border-0 p-0 ${photos[0].media_type === 'video' ? 'bg-black' : 'bg-transparent'
}`}
onClick={() => onPhotoClick(photos, 0)}
/>
>
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur">
<Play size={20} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</button>
{photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<Camera size={12} />
{t('mobileJourney.photosCount', { count: photos.length })}
</div>
)}
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
{photos.map((p, i) => (
<img
<button
key={p.id || i}
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
type="button"
aria-label={p.caption || t('journey.photos')}
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
}`}
onClick={() => onPhotoClick(photos, i)}
/>
>
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Play
size={16}
className="text-white drop-shadow"
fill="currentColor"
/>
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="px-5 py-5 pb-32">
@@ -224,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
)}
</div>
</div>
</div>
</div >
)
}
@@ -46,7 +46,7 @@ export default function MobileMapTimeline({
const [activeIndex, setActiveIndex] = useState(0)
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
@@ -171,7 +171,7 @@ export default function MobileMapTimeline({
/>
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button
<button type="button"
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
@@ -244,7 +244,7 @@ export default function MobileMapTimeline({
className="fixed right-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
>
<button
<button type="button"
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
@@ -86,7 +86,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
<button type="button" onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
@@ -97,7 +97,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
<button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
@@ -127,7 +127,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{/* Next button */}
{hasNext && (
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
<button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
+46 -6
View File
@@ -25,27 +25,67 @@ export default function VideoPlayer({
useEffect(() => {
const el = videoRef.current
if (!el) return
const player = new Plyr(el, {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'fullscreen',
],
autoplay: autoPlay,
// Keep playback inline so the lightbox stays in control on mobile.
clickToPlay: true,
hideControls: false,
})
return () => { try { player.destroy() } catch { /* already torn down */ } }
return () => {
try {
player.destroy()
} catch {
/* already torn down */
}
}
}, [src, autoPlay])
return (
<div
className="trek-video-player"
style={{
width: 'min(92vw, 1100px)',
maxHeight: '92vh',
height: 'min(88vh, 900px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#000',
borderRadius: 4,
overflow: 'hidden',
animation: 'fadeIn 0.15s ease',
...style,
}}
>
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata" />
<video
ref={videoRef}
src={src}
poster={poster}
playsInline
controls
preload="metadata"
style={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
background: '#000',
}}
>
{/* Gallery uploads carry no caption file; the empty track keeps the element valid. */}
<track kind="captions" />
</video>
</div>
)
}
}
+71 -3
View File
@@ -3,10 +3,13 @@
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
*/
export function stripMarkdown(md: string): string {
return md
const withoutImages = md
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
// the alt text is a run that cannot contain "](", so it ends on the first one
// instead of being retried against every later "](". Same matches, no backtracking
.replace(/!\[(?:(?!\]\().)*\]\(.*?\)/g, '') // images
return stripLinks(withoutImages) // links → text
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
.replace(/`([^`]+)`/g, '$1') // inline code
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
@@ -22,3 +25,68 @@ export function stripMarkdown(md: string): string {
.replace(/\n/g, ' ') // remaining newlines → spaces
.trim()
}
/**
* `.` matches every character except a line terminator, which is exactly the set a link
* target could be built from back when this was `.*?`. Testing a single character against
* it spells that out without hand-listing \n, \r and the two Unicode separators.
*/
const notALineTerminator = /./
/**
* Replace `[label](target)` with its label, exactly as `/\[([^\]]*)\]\(.*?\)/g` did.
*
* Written as a scan rather than a regex because that pattern backtracks: `[^\]]` matches
* `[` too, so on a run of `[` with no `]` after it the engine retries the whole
* label-and-target scan from every one of them and reads to the end each time. Measured
* on 32k of `[x`: 300ms, quadrupling with every doubling. The subject is a journal
* entry's story text, which any contributor to the journal can write and every reader's
* browser then renders a preview of.
*
* The semantics are the regex's, quirks included:
* - the label runs to the FIRST `]` and may itself contain `[`, so `[a[b](c)` keeps
* `a[b` — narrowing the label to `[^[\]]` would have been a behaviour change;
* - the label may be empty, since `[^\]]*` is zero-or-more, so `[](c)` is a link;
* - `(` has to sit directly after that `]`;
* - the target ends at the first `)`, and `.` cannot cross a line terminator, so a `(`
* whose first `)` only turns up on a later line is not a link at all.
*/
function stripLinks(input: string): string {
let out = ''
let cursor = 0 // everything before this has already been copied into `out`
let search = 0 // where the next `[` is looked for
// First `)`-or-line-terminator at or after the target we last examined. Targets are
// examined left to right, so this only ever moves forward — which is what stops a
// document full of `[a](` from being re-read once per candidate.
let stop = 0
for (;;) {
const open = input.indexOf('[', search)
if (open === -1) break
const close = input.indexOf(']', open + 1)
// No `]` left anywhere: neither this `[` nor any after it can match. Rediscovering
// that from every `[` in turn is what the regex spent quadratic time on.
if (close === -1) break
// Every `[` between `open` and `close` has that same first `]`, so the verdict on
// what follows it is the verdict for all of them — skip the lot when it fails.
if (input[close + 1] !== '(') {
search = close + 1
continue
}
if (stop < close + 2) stop = close + 2
while (stop < input.length && input[stop] !== ')' && notALineTerminator.test(input[stop])) stop++
if (input[stop] !== ')') {
search = close + 1
continue
}
out += input.slice(cursor, open) + input.slice(open + 1, close)
cursor = stop + 1
search = stop + 1
}
return out + input.slice(cursor)
}
+2 -2
View File
@@ -84,7 +84,7 @@ export default function BottomNav() {
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
const active = isActive(to)
return (
<button
<button type="button"
key={to}
onClick={() => navigate(to)}
className="flex flex-col items-center gap-1 py-1 px-1 min-w-0"
@@ -115,7 +115,7 @@ export default function BottomNav() {
>
<div className="flex flex-1 items-center justify-around min-w-0">{left.map(renderItem)}</div>
<button
<button type="button"
onClick={create.run}
aria-label={create.label}
className="flex items-center justify-center flex-shrink-0 active:scale-95 transition-transform"
+12 -1
View File
@@ -304,9 +304,20 @@ export default function DemoBanner(): React.ReactElement | null {
overflow: 'auto',
fontFamily: 'var(--font-system)',
}}
role="button"
tabIndex={0}
aria-label={t.close}
onClick={() => setDismissed(true)}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDismissed(true);
}
}}
>
<div
role="presentation"
style={{
background: 'white',
borderRadius: 20,
@@ -547,7 +558,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.selfHostLink}
</a>
</div>
<button
<button type="button"
onClick={() => setDismissed(true)}
style={{
background: '#111827',
@@ -62,7 +62,7 @@ describe('InAppNotificationBell', () => {
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
// Panel shows "Notifications" title
await screen.findByText('Notifications');
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('FE-COMP-BELL-004: notification panel shows empty state when no notifications', async () => {
@@ -76,7 +76,7 @@ describe('InAppNotificationBell', () => {
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
await screen.findByText('No notifications');
expect(await screen.findByText('No notifications')).toBeInTheDocument();
});
it('FE-COMP-BELL-005: shows unread badge count when there are unread notifications', async () => {
@@ -98,7 +98,7 @@ describe('InAppNotificationBell', () => {
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
await screen.findByTitle('Mark all read');
expect(await screen.findByTitle('Mark all read')).toBeInTheDocument();
});
it('FE-COMP-BELL-008: panel shows empty description when no notifications', async () => {
@@ -111,7 +111,7 @@ describe('InAppNotificationBell', () => {
const user = userEvent.setup();
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText("You're all caught up!");
expect(await screen.findByText("You're all caught up!")).toBeInTheDocument();
});
it('FE-COMP-BELL-009: bell is accessible as a button', () => {

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