Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 |
@@ -5,6 +5,24 @@ client/dist
|
|||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
|
.github
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
*.md
|
*.md
|
||||||
|
!client/**/*.md
|
||||||
|
chart/
|
||||||
|
docs/
|
||||||
|
docker-compose.yml
|
||||||
|
unraid-template.xml
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ client/public/icons/*.png
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
# User data
|
# User data
|
||||||
server/data/
|
server/data/
|
||||||
@@ -28,6 +31,7 @@ Thumbs.db
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# TREK Security & Code Quality Audit
|
||||||
|
|
||||||
|
**Date:** 2026-03-30
|
||||||
|
**Auditor:** Automated comprehensive audit
|
||||||
|
**Scope:** Full codebase — server, client, infrastructure, dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Security](#1-security)
|
||||||
|
2. [Code Quality](#2-code-quality)
|
||||||
|
3. [Best Practices](#3-best-practices)
|
||||||
|
4. [Dependency Hygiene](#4-dependency-hygiene)
|
||||||
|
5. [Documentation & DX](#5-documentation--dx)
|
||||||
|
6. [Testing](#6-testing)
|
||||||
|
7. [Remediation Summary](#7-remediation-summary)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security
|
||||||
|
|
||||||
|
### 1.1 General
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| S-1 | **CRITICAL** | `server/src/middleware/auth.ts` | 17 | JWT `verify()` does not pin algorithm — accepts whatever algorithm is in the token header, potentially including `none`. | Pass `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls. | FIXED |
|
||||||
|
| S-2 | **HIGH** | `server/src/websocket.ts` | 56 | Same JWT verify without algorithm pinning in WebSocket auth. | Pin algorithm to HS256. | FIXED |
|
||||||
|
| S-3 | **HIGH** | `server/src/middleware/mfaPolicy.ts` | 54 | Same JWT verify without algorithm pinning. | Pin algorithm to HS256. | FIXED |
|
||||||
|
| S-4 | **HIGH** | `server/src/routes/oidc.ts` | 84-88 | OIDC `generateToken()` includes excessive claims (username, email, role) in JWT payload. If the JWT is leaked, this exposes PII. | Only include `{ id: user.id }` in token, consistent with auth.ts. | FIXED |
|
||||||
|
| S-5 | **HIGH** | `client/src/api/websocket.ts` | 27 | Auth token passed in WebSocket URL query string (`?token=`). Tokens in URLs appear in server logs, proxy logs, and browser history. | Document as known limitation; WebSocket protocol doesn't easily support headers from browsers. Add `LOW` priority note to switch to message-based auth in the future. | DOCUMENTED |
|
||||||
|
| S-6 | **HIGH** | `client/vite.config.js` | 47-56 | Service worker caches ALL `/api/.*` responses with `NetworkFirst`, including auth tokens, user data, budget, reservations. Data persists after logout. | Exclude sensitive API paths from caching: `/api/auth/.*`, `/api/admin/.*`, `/api/backup/.*`. | FIXED |
|
||||||
|
| S-7 | **HIGH** | `client/vite.config.js` | 57-65 | User-uploaded files (possibly passport scans, booking confirmations) cached with `CacheFirst` for 30 days, persisting after logout. | Reduce cache lifetime; add note about clearing on logout. | FIXED |
|
||||||
|
| S-8 | **MEDIUM** | `server/src/index.ts` | 60 | CSP allows `'unsafe-inline'` for scripts, weakening XSS protection. | Remove `'unsafe-inline'` from `scriptSrc` if Vite build doesn't require it. If needed for development, only allow in non-production. | FIXED |
|
||||||
|
| S-9 | **MEDIUM** | `server/src/index.ts` | 64 | CSP `connectSrc` allows `http:` and `https:` broadly, permitting connections to any origin. | Restrict to known API domains (nominatim, overpass, Google APIs) or use `'self'` with specific external origins. | FIXED |
|
||||||
|
| S-10 | **MEDIUM** | `server/src/index.ts` | 62 | CSP `imgSrc` allows `http:` broadly. | Restrict to `https:` and `'self'` plus known image domains. | FIXED |
|
||||||
|
| S-11 | **MEDIUM** | `server/src/websocket.ts` | 84-90 | No message size limit on WebSocket messages. A malicious client could send very large messages to exhaust server memory. | Set `maxPayload` on WebSocketServer configuration. | FIXED |
|
||||||
|
| S-12 | **MEDIUM** | `server/src/websocket.ts` | 84 | No rate limiting on WebSocket messages. A client can flood the server with join/leave messages. | Add per-connection message rate limiting. | FIXED |
|
||||||
|
| S-13 | **MEDIUM** | `server/src/websocket.ts` | 29 | No origin validation on WebSocket connections. | Add origin checking against allowed origins. | FIXED |
|
||||||
|
| S-14 | **MEDIUM** | `server/src/routes/auth.ts` | 157-163 | JWT tokens have 24h expiry with no refresh token mechanism. Long-lived tokens increase window of exposure if leaked. | Document as accepted risk for self-hosted app. Consider refresh tokens in future. | DOCUMENTED |
|
||||||
|
| S-15 | **MEDIUM** | `server/src/routes/auth.ts` | 367-368 | Password change does not invalidate existing JWT tokens. Old tokens remain valid for up to 24h. | Implement token version/generation tracking, or reduce token expiry and add refresh tokens. | REQUIRES MANUAL REVIEW |
|
||||||
|
| S-16 | **MEDIUM** | `server/src/services/mfaCrypto.ts` | 2, 5 | MFA encryption key is derived from JWT_SECRET. If JWT_SECRET is compromised, all MFA secrets are also compromised. Single point of failure. | Use a separate MFA_ENCRYPTION_KEY env var, or derive using a different salt/purpose. Current implementation with `:mfa:v1` salt is acceptable but tightly coupled. | DOCUMENTED |
|
||||||
|
| S-17 | **MEDIUM** | `server/src/routes/maps.ts` | 429 | Google API key exposed in URL query string (`&key=${apiKey}`). Could appear in logs. | Use header-based auth (X-Goog-Api-Key) consistently. Already used elsewhere in the file. | FIXED |
|
||||||
|
| S-18 | **MEDIUM** | `MCP.md` | 232-235 | Contains publicly accessible database download link with hardcoded credentials (`admin@admin.com` / `admin123`). | Remove credentials from documentation. | FIXED |
|
||||||
|
| S-19 | **LOW** | `server/src/index.ts` | 229 | Error handler logs full error object including stack trace to console. In containerized deployments, this could leak to centralized logging. | Sanitize error logging in production. | FIXED |
|
||||||
|
| S-20 | **LOW** | `server/src/routes/backup.ts` | 301-304 | Error detail leaked in non-production environments (`detail: process.env.NODE_ENV !== 'production' ? msg : undefined`). | Acceptable for dev, but ensure it's consistently not leaked in production. Already correct. | OK |
|
||||||
|
|
||||||
|
### 1.2 Auth (JWT + OIDC + TOTP)
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| A-1 | **CRITICAL** | All jwt.verify calls | Multiple | JWT algorithm not pinned. `jsonwebtoken` library defaults to accepting the algorithm specified in the token header, which could include `none`. | Add `{ algorithms: ['HS256'] }` to every `jwt.verify()` call. | FIXED |
|
||||||
|
| A-2 | **MEDIUM** | `server/src/routes/auth.ts` | 315-318 | MFA login token uses same JWT_SECRET and same `jwt.sign()`. Purpose field `mfa_login` prevents misuse but should use a shorter expiry. Currently 5m which is acceptable. | OK — 5 minute expiry is reasonable. | OK |
|
||||||
|
| A-3 | **MEDIUM** | `server/src/routes/oidc.ts` | 113-143 | OIDC redirect URI is dynamically constructed from request headers (`x-forwarded-proto`, `x-forwarded-host`). An attacker who can control these headers could redirect the callback to a malicious domain. | Validate the constructed redirect URI against an allowlist, or use a configured base URL from env vars. | FIXED |
|
||||||
|
| A-4 | **LOW** | `server/src/routes/auth.ts` | 21 | TOTP `window: 1` allows codes from adjacent time periods (±30s). This is standard and acceptable. | OK | OK |
|
||||||
|
|
||||||
|
### 1.3 SQLite (better-sqlite3)
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| D-1 | **HIGH** | `server/src/routes/files.ts` | 90-91 | Dynamic SQL with `IN (${placeholders})` — however, placeholders are correctly generated from array length and values are parameterized. **Not an injection risk.** | OK — pattern is safe. | OK |
|
||||||
|
| D-2 | **MEDIUM** | `server/src/routes/auth.ts` | 455 | Dynamic SQL `UPDATE users SET ${updates.join(', ')} WHERE id = ?` — column names come from controlled server-side code, not user input. Parameters are properly bound. | OK — column names are from a controlled set. | OK |
|
||||||
|
| D-3 | **LOW** | `server/src/db/database.ts` | 26-28 | WAL mode and busy_timeout configured. Good. | OK | OK |
|
||||||
|
|
||||||
|
### 1.4 WebSocket (ws)
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| W-1 | **MEDIUM** | `server/src/websocket.ts` | 29 | No `maxPayload` set on WebSocketServer. Default is 100MB which is excessive. | Set `maxPayload: 64 * 1024` (64KB). | FIXED |
|
||||||
|
| W-2 | **MEDIUM** | `server/src/websocket.ts` | 84-110 | Only `join` and `leave` message types are handled; unknown types are silently ignored. This is acceptable but there is no schema validation on the message structure. | Add basic type/schema validation using Zod. | FIXED |
|
||||||
|
| W-3 | **LOW** | `server/src/websocket.ts` | 88 | `JSON.parse` errors are silently caught with empty catch. | Log malformed messages at debug level. | FIXED |
|
||||||
|
|
||||||
|
### 1.5 Express
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| E-1 | **LOW** | `server/src/index.ts` | 82 | Body parser limit set to 100KB. Good. | OK | OK |
|
||||||
|
| E-2 | **LOW** | `server/src/index.ts` | 14-16 | Trust proxy configured conditionally. Good. | OK | OK |
|
||||||
|
| E-3 | **LOW** | `server/src/index.ts` | 121-136 | Path traversal protection on uploads endpoint. Uses `path.basename` and `path.resolve` check. Good. | OK | OK |
|
||||||
|
|
||||||
|
### 1.6 PWA / Workbox
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| P-1 | **HIGH** | `client/vite.config.js` | 47-56 | API response caching includes sensitive endpoints. | Exclude auth, admin, backup, and settings endpoints from caching. | FIXED |
|
||||||
|
| P-2 | **MEDIUM** | `client/vite.config.js` | 23, 31, 42, 54, 63 | `cacheableResponse: { statuses: [0, 200] }` — status 0 represents opaque responses which may cache error responses silently. | Remove status 0 from API and upload caches (keep for CDN/map tiles where CORS may return opaque responses). | FIXED |
|
||||||
|
| P-3 | **MEDIUM** | `client/src/store/authStore.ts` | 126-135 | Logout does not clear service worker caches. Sensitive data persists after logout. | Clear CacheStorage for `api-data` and `user-uploads` caches on logout. | FIXED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Quality
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| Q-1 | **MEDIUM** | `client/src/store/authStore.ts` | 153-161 | `loadUser` silently catches all errors and logs user out. A transient network failure logs the user out. | Only logout on 401 responses, not on network errors. | FIXED |
|
||||||
|
| Q-2 | **MEDIUM** | `client/src/hooks/useRouteCalculation.ts` | 36 | `useCallback` depends on entire `tripStore` object, defeating memoization. | Select only needed properties from the store. | DOCUMENTED |
|
||||||
|
| Q-3 | **MEDIUM** | `client/src/hooks/useTripWebSocket.ts` | 14 | `collabFileSync` captures stale `tripStore` reference from initial render. | Use `useTripStore.getState()` instead. | DOCUMENTED |
|
||||||
|
| Q-4 | **MEDIUM** | `client/src/store/authStore.ts` | 38 vs 105 | `register` function accepts 4 params but TypeScript interface only declares 3. | Update interface to include optional `invite_token`. | FIXED |
|
||||||
|
| Q-5 | **LOW** | `client/src/store/slices/filesSlice.ts` | — | Empty catch block on file link operation (`catch {}`). | Log error. | DOCUMENTED |
|
||||||
|
| Q-6 | **LOW** | `client/src/App.tsx` | 101, 108 | Empty catch blocks silently swallow errors. | Add minimal error logging. | DOCUMENTED |
|
||||||
|
| Q-7 | **LOW** | `client/src/App.tsx` | 155 | `RegisterPage` imported but never used — `/register` route renders `LoginPage`. | Remove unused import. | FIXED |
|
||||||
|
| Q-8 | **LOW** | `client/tsconfig.json` | 14 | `strict: false` disables TypeScript strict mode. | Enable strict mode and fix resulting type errors. | REQUIRES MANUAL REVIEW |
|
||||||
|
| Q-9 | **LOW** | `client/src/main.tsx` | 7 | Non-null assertion on `getElementById('root')!`. | Add null check. | DOCUMENTED |
|
||||||
|
| Q-10 | **LOW** | `server/src/routes/files.ts` | 278 | Empty catch block on file link insert (`catch {}`). | Log duplicate link errors. | FIXED |
|
||||||
|
| Q-11 | **LOW** | `server/src/db/database.ts` | 20-21 | Silent catch on WAL checkpoint in `initDb`. | Log warning on failure. | DOCUMENTED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Best Practices
|
||||||
|
|
||||||
|
### 3.1 Node / Express
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| B-1 | **LOW** | `server/src/index.ts` | 251-271 | Graceful shutdown implemented with SIGTERM/SIGINT handlers. Good — closes DB, HTTP server, with 10s timeout. | OK | OK |
|
||||||
|
| B-2 | **LOW** | `server/src/index.ts` | 87-112 | Debug logging redacts sensitive fields. Good. | OK | OK |
|
||||||
|
|
||||||
|
### 3.2 React / Vite
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| V-1 | **MEDIUM** | `client/vite.config.js` | — | No explicit `build.sourcemap: false` for production. Source maps may be generated. | Add `build: { sourcemap: false }` to Vite config. | FIXED |
|
||||||
|
| V-2 | **LOW** | `client/index.html` | 24 | Leaflet CSS loaded from unpkg CDN without Subresource Integrity (SRI) hash. | Add `integrity` and `crossorigin` attributes. | FIXED |
|
||||||
|
|
||||||
|
### 3.3 Docker
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| K-1 | **MEDIUM** | `Dockerfile` | 2, 10 | Base images use floating tags (`node:22-alpine`), not pinned to digest. | Pin to specific digest for reproducible builds. | DOCUMENTED |
|
||||||
|
| K-2 | **MEDIUM** | `Dockerfile` | — | No `HEALTHCHECK` instruction. Only docker-compose has health check. | Add `HEALTHCHECK` to Dockerfile for standalone deployments. | FIXED |
|
||||||
|
| K-3 | **LOW** | `.dockerignore` | — | Missing exclusions for `chart/`, `docs/`, `.github/`, `docker-compose.yml`, `*.sqlite*`. | Add missing exclusions. | FIXED |
|
||||||
|
|
||||||
|
### 3.4 docker-compose.yml
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| C-1 | **HIGH** | `docker-compose.yml` | 25 | `JWT_SECRET` defaults to empty string if not set. App auto-generates one, but it changes on restart, invalidating all sessions. | Log a prominent warning on startup if JWT_SECRET is auto-generated. | FIXED |
|
||||||
|
| C-2 | **MEDIUM** | `docker-compose.yml` | — | No resource limits defined for the `app` service. | Add `deploy.resources.limits` section. | DOCUMENTED |
|
||||||
|
|
||||||
|
### 3.5 Git Hygiene
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| G-1 | **HIGH** | `.gitignore` | 12-14 | Missing `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` patterns. Only `*.db` variants covered. | Add sqlite patterns. | FIXED |
|
||||||
|
| G-2 | **LOW** | — | — | No `.env` or `.sqlite` files found in git history. | OK | OK |
|
||||||
|
|
||||||
|
### 3.6 Helm Chart
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| H-1 | **MEDIUM** | `chart/templates/secret.yaml` | 22 | `randAlphaNum 32` generates a new JWT secret on every `helm upgrade`, invalidating all sessions. | Use `lookup` to preserve existing secret across upgrades. | FIXED |
|
||||||
|
| H-2 | **MEDIUM** | `chart/values.yaml` | 3 | Default image tag is `latest`. | Use a specific version tag. | DOCUMENTED |
|
||||||
|
| H-3 | **MEDIUM** | `chart/templates/deployment.yaml` | — | No `securityContext` on pod or container. Runs as root by default. | Add `runAsNonRoot: true`, `runAsUser: 1000`. | FIXED |
|
||||||
|
| H-4 | **MEDIUM** | `chart/templates/pvc.yaml` | — | PVC always created regardless of `.Values.persistence.enabled`. | Add conditional check. | FIXED |
|
||||||
|
| H-5 | **LOW** | `chart/values.yaml` | 41 | `resources: {}` — no default resource requests or limits. | Add sensible defaults. | FIXED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dependency Hygiene
|
||||||
|
|
||||||
|
### 4.1 npm audit
|
||||||
|
|
||||||
|
| Package | Severity | Description | Status |
|
||||||
|
|---------|----------|-------------|--------|
|
||||||
|
| `serialize-javascript` (via vite-plugin-pwa → workbox-build → @rollup/plugin-terser) | **HIGH** | RCE via RegExp.flags / CPU exhaustion DoS | Fix requires `vite-plugin-pwa` major version upgrade. | DOCUMENTED |
|
||||||
|
| `picomatch` (via @rollup/pluginutils, tinyglobby) | **MODERATE** | ReDoS via extglob quantifiers | `npm audit fix` available. | FIXED |
|
||||||
|
|
||||||
|
**Server:** 0 vulnerabilities.
|
||||||
|
|
||||||
|
### 4.2 Outdated Dependencies (Notable)
|
||||||
|
|
||||||
|
| Package | Current | Latest | Risk | Status |
|
||||||
|
|---------|---------|--------|------|--------|
|
||||||
|
| `express` | ^4.18.3 | 5.2.1 | Major version — breaking changes | DOCUMENTED |
|
||||||
|
| `uuid` | ^9.0.0 | 13.0.0 | Major version | DOCUMENTED |
|
||||||
|
| `dotenv` | ^16.4.1 | 17.3.1 | Major version | DOCUMENTED |
|
||||||
|
| `lucide-react` | ^0.344.0 | 1.7.0 | Major version | DOCUMENTED |
|
||||||
|
| `react` | ^18.2.0 | 19.2.4 | Major version | DOCUMENTED |
|
||||||
|
| `zustand` | ^4.5.2 | 5.0.12 | Major version | DOCUMENTED |
|
||||||
|
|
||||||
|
> Major version upgrades require manual evaluation and testing. Not applied in this remediation pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Documentation & DX
|
||||||
|
|
||||||
|
| # | Severity | File | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|-------------|-----------------|--------|
|
||||||
|
| X-1 | **MEDIUM** | `server/.env.example` | Missing many env vars documented in README: `OIDC_*`, `FORCE_HTTPS`, `TRUST_PROXY`, `DEMO_MODE`, `TZ`, `ALLOWED_ORIGINS`, `DEBUG`. | Add all configurable env vars. | FIXED |
|
||||||
|
| X-2 | **MEDIUM** | `server/.env.example` | JWT_SECRET placeholder is `your-super-secret-jwt-key-change-in-production` — easily overlooked. | Use `CHANGEME_GENERATE_WITH_openssl_rand_hex_32`. | FIXED |
|
||||||
|
| X-3 | **LOW** | `server/.env.example` | `PORT=3001` differs from Docker default of `3000`. | Align to `3000`. | FIXED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
| # | Severity | Description | Status |
|
||||||
|
|---|----------|-------------|--------|
|
||||||
|
| T-1 | **HIGH** | No test files found anywhere in the repository. Zero test coverage for auth flows, WebSocket handling, SQLite queries, API routes, or React components. | REQUIRES MANUAL REVIEW |
|
||||||
|
| T-2 | **HIGH** | No test framework configured (no jest, vitest, or similar in dependencies). | REQUIRES MANUAL REVIEW |
|
||||||
|
| T-3 | **MEDIUM** | No CI step runs tests before building Docker image. | DOCUMENTED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Remediation Summary
|
||||||
|
|
||||||
|
### Applied Fixes
|
||||||
|
|
||||||
|
- **Immich SSRF prevention** — Added URL validation on save (block private IPs, metadata endpoints, non-HTTP protocols)
|
||||||
|
- **Immich API key isolation** — Removed `userId` query parameter from asset proxy endpoints; all Immich requests now use authenticated user's own credentials only
|
||||||
|
- **Immich asset ID validation** — Added alphanumeric pattern validation to prevent path traversal in proxied URLs
|
||||||
|
- **JWT algorithm pinning** — Added `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls (auth middleware, MFA policy, WebSocket, OIDC, auth routes)
|
||||||
|
- **OIDC token payload** — Reduced to `{ id }` only, matching auth.ts pattern
|
||||||
|
- **OIDC redirect URI validation** — Validates against `APP_URL` env var when set
|
||||||
|
- **WebSocket hardening** — Added `maxPayload: 64KB`, message rate limiting (30 msg/10s), origin validation, improved message validation
|
||||||
|
- **CSP tightening** — Removed `'unsafe-inline'` from scripts in production, restricted `connectSrc` and `imgSrc` to known domains
|
||||||
|
- **PWA cache security** — Excluded sensitive API paths from caching, removed opaque response caching for API/uploads, clear caches on logout
|
||||||
|
- **Service worker cache cleanup on logout**
|
||||||
|
- **Google API key** — Moved from URL query string to header in maps photo endpoint
|
||||||
|
- **MCP.md credentials** — Removed hardcoded demo credentials
|
||||||
|
- **.gitignore** — Added `*.sqlite*` patterns
|
||||||
|
- **.dockerignore** — Added missing exclusions
|
||||||
|
- **Dockerfile** — Added HEALTHCHECK instruction
|
||||||
|
- **Helm chart** — Fixed secret rotation, added securityContext, conditional PVC, resource defaults
|
||||||
|
- **Vite config** — Disabled source maps in production
|
||||||
|
- **CDN integrity** — Added SRI hash for Leaflet CSS
|
||||||
|
- **.env.example** — Complete with all env vars
|
||||||
|
- **Various code quality fixes** — Removed dead imports, fixed empty catch blocks, fixed auth store interface
|
||||||
|
|
||||||
|
### Requires Manual Review
|
||||||
|
|
||||||
|
- Password change should invalidate existing tokens (S-15)
|
||||||
|
- TypeScript strict mode should be enabled (Q-8)
|
||||||
|
- Test suite needs to be created from scratch (T-1, T-2)
|
||||||
|
- Major dependency upgrades (express 5, React 19, zustand 5, etc.)
|
||||||
|
- `serialize-javascript` vulnerability fix requires vite-plugin-pwa major upgrade
|
||||||
|
|
||||||
|
### 1.7 Immich Integration
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| I-1 | **CRITICAL** | `server/src/routes/immich.ts` | 38-39, 85, 199, 250, 274 | SSRF via user-controlled `immich_url`. Users can set any URL which is then used in `fetch()` calls, allowing requests to internal metadata endpoints (169.254.169.254), localhost services, etc. | Validate URL on save: require HTTP(S) protocol, block private/internal IPs. | FIXED |
|
||||||
|
| I-2 | **CRITICAL** | `server/src/routes/immich.ts` | 194-196, 244-246, 269-270 | Asset info/thumbnail/original endpoints accept `userId` query param, allowing any authenticated user to proxy requests through another user's Immich API key. This exposes other users' Immich credentials and photo libraries. | Restrict all Immich proxy endpoints to the authenticated user's own credentials only. | FIXED |
|
||||||
|
| I-3 | **MEDIUM** | `server/src/routes/immich.ts` | 199, 250, 274 | `assetId` URL parameter used directly in `fetch()` URL construction. Path traversal characters could redirect requests to unintended Immich API endpoints. | Validate assetId matches `[a-zA-Z0-9_-]+` pattern. | FIXED |
|
||||||
|
|
||||||
|
### 1.8 Admin Routes
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| AD-1 | **MEDIUM** | `server/src/routes/admin.ts` | 302-310 | Self-update endpoint runs `git pull` then `npm run build`. While admin-only and `npm install` uses `--ignore-scripts`, `npm run build` executes whatever is in the pulled package.json. A compromised upstream could execute arbitrary code. | Document as accepted risk for self-hosted self-update feature. Users should pin to specific versions. | DOCUMENTED |
|
||||||
|
|
||||||
|
### 1.9 Client-Side XSS
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| X-1 | **CRITICAL** | `client/src/components/Admin/GitHubPanel.tsx` | 66, 106 | `dangerouslySetInnerHTML` with `inlineFormat()` renders GitHub release notes as HTML without escaping. Malicious HTML in release notes could execute scripts. | Escape HTML entities before applying markdown-style formatting. Validate link URLs. | FIXED |
|
||||||
|
| X-2 | **LOW** | `client/src/components/Map/MapView.tsx` | divIcon | Uses `escAttr()` for HTML sanitization in divIcon strings. Properly mitigated. | OK | OK |
|
||||||
|
|
||||||
|
### 1.10 Route Calculator Bug
|
||||||
|
|
||||||
|
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||||
|
|---|----------|------|---------|-------------|-----------------|--------|
|
||||||
|
| RC-1 | **HIGH** | `client/src/components/Map/RouteCalculator.ts` | 16 | OSRM URL hardcodes `'driving'` profile, ignoring the `profile` parameter. Walking/cycling routes always return driving results. | Use the `profile` parameter in URL construction. | FIXED |
|
||||||
|
|
||||||
|
### Additional Findings (from exhaustive scan)
|
||||||
|
|
||||||
|
- **MEDIUM** — `server/src/index.ts:121-136`: Upload files (`/uploads/:type/:filename`) served without authentication. UUIDs are unguessable but this is security-through-obscurity. **REQUIRES MANUAL REVIEW** — adding auth would break shared trip image URLs.
|
||||||
|
- **MEDIUM** — `server/src/routes/oidc.ts:194`: OIDC token exchange error was logging full token response (potentially including access tokens). **FIXED** — now logs only HTTP status.
|
||||||
|
- **MEDIUM** — `server/src/services/notifications.ts:194-196`: Email body is not HTML-escaped. User-generated content (trip names, usernames) interpolated directly into HTML email template. Potential stored XSS in email clients. **DOCUMENTED** — needs HTML entity escaping.
|
||||||
|
- **LOW** — `server/src/demo/demo-seed.ts:7-9`: Hardcoded demo credentials (`demo12345`, `admin12345`). Intentional for demo mode but dangerous if DEMO_MODE accidentally left on in production. Already has startup warning.
|
||||||
|
- **LOW** — `server/src/routes/auth.ts:742`: MFA setup returns plaintext TOTP secret to client. This is standard TOTP enrollment flow — users need the secret for manual entry. Must be served over HTTPS.
|
||||||
|
- **LOW** — `server/src/routes/auth.ts:473`: Admin settings GET returns API keys in full (not masked). Only accessible to admins.
|
||||||
|
- **LOW** — `server/src/routes/auth.ts:564`: SMTP password stored as plaintext in `app_settings` table. Masked in API response but unencrypted at rest.
|
||||||
|
|
||||||
|
### Accepted Risks (Documented)
|
||||||
|
|
||||||
|
- WebSocket token in URL query string (browser limitation)
|
||||||
|
- 24h JWT expiry without refresh tokens (acceptable for self-hosted)
|
||||||
|
- MFA encryption key derived from JWT_SECRET (noted coupling)
|
||||||
|
- localStorage for token storage (standard SPA pattern)
|
||||||
|
- Upload files served without auth (UUID-based obscurity, needed for shared trips)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Stage 1: React Client bauen
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:22-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
@@ -6,37 +6,32 @@ RUN npm ci
|
|||||||
COPY client/ ./
|
COPY client/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Produktions-Server
|
# Stage 2: Production server
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache tzdata python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
apk del python3 make g++
|
||||||
|
|
||||||
# Server-Code kopieren
|
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
|
|
||||||
# Gebauten Client kopieren
|
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
COPY --from=client-builder /app/client/dist ./public
|
||||||
|
|
||||||
# Fonts für PDF-Export kopieren
|
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
chown -R node:node /app
|
||||||
|
|
||||||
RUN chown -R node:node /app
|
|
||||||
USER node
|
|
||||||
|
|
||||||
# Umgebung setzen
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# MCP Integration
|
||||||
|
|
||||||
|
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||||
|
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||||
|
structured API.
|
||||||
|
|
||||||
|
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Setup](#setup)
|
||||||
|
- [Limitations & Important Notes](#limitations--important-notes)
|
||||||
|
- [Resources (read-only)](#resources-read-only)
|
||||||
|
- [Tools (read-write)](#tools-read-write)
|
||||||
|
- [Example](#example)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Enable the MCP addon (admin)
|
||||||
|
|
||||||
|
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||||
|
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||||
|
|
||||||
|
### 2. Create an API token
|
||||||
|
|
||||||
|
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||||
|
|
||||||
|
1. Click **Create New Token**
|
||||||
|
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||||
|
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||||
|
|
||||||
|
Each user can create up to **10 tokens**.
|
||||||
|
|
||||||
|
### 3. Configure your MCP client
|
||||||
|
|
||||||
|
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||||
|
`claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"trek": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://your-trek-instance.com/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer trek_your_token_here"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations & Important Notes
|
||||||
|
|
||||||
|
| Limitation | Details |
|
||||||
|
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||||
|
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||||
|
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||||
|
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||||
|
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||||
|
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||||
|
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||||
|
| **Token limits** | Maximum 10 API tokens per user. |
|
||||||
|
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||||
|
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources (read-only)
|
||||||
|
|
||||||
|
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||||
|
making changes.
|
||||||
|
|
||||||
|
| Resource | URI | Description |
|
||||||
|
|-------------------|--------------------------------------------|-----------------------------------------------------------|
|
||||||
|
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||||
|
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||||
|
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||||
|
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
|
||||||
|
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||||
|
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||||
|
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||||
|
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||||
|
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||||
|
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||||
|
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||||
|
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||||
|
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||||
|
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools (read-write)
|
||||||
|
|
||||||
|
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
|
||||||
|
about a trip in a single call.
|
||||||
|
|
||||||
|
### Trip Summary
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
|
||||||
|
|
||||||
|
### Trips
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---------------|---------------------------------------------------------------------------------------------|
|
||||||
|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||||
|
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||||
|
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||||
|
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||||
|
|
||||||
|
### Places
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------|-----------------------------------------------------------------------------------|
|
||||||
|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
|
||||||
|
| `update_place` | Update any field of an existing place. |
|
||||||
|
| `delete_place` | Remove a place from a trip. |
|
||||||
|
|
||||||
|
### Day Planning
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---------------------------|-------------------------------------------------------------------------------|
|
||||||
|
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||||
|
| `unassign_place` | Remove a place assignment from a day. |
|
||||||
|
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||||
|
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). |
|
||||||
|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||||
|
|
||||||
|
### Reservations
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||||
|
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||||
|
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||||
|
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||||
|
|
||||||
|
### Budget
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------|--------------------------------------------------------------|
|
||||||
|
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||||
|
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||||
|
| `delete_budget_item` | Remove a budget item. |
|
||||||
|
|
||||||
|
### Packing
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------|--------------------------------------------------------------|
|
||||||
|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||||
|
| `update_packing_item` | Rename an item or change its category. |
|
||||||
|
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||||
|
| `delete_packing_item` | Remove a packing item. |
|
||||||
|
|
||||||
|
### Day Notes
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------|-----------------------------------------------------------------------|
|
||||||
|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||||
|
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||||
|
| `delete_day_note` | Remove a note from a day. |
|
||||||
|
|
||||||
|
### Collab Notes
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||||
|
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||||
|
| `delete_collab_note` | Delete a collab note and its associated files. |
|
||||||
|
|
||||||
|
### Bucket List
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||||
|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||||
|
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||||
|
|
||||||
|
### Atlas
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|--------------------------|--------------------------------------------------------------------------------|
|
||||||
|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||||
|
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||||
|
|
||||||
|
Initial prompt (1st message):
|
||||||
|
|
||||||
|
```
|
||||||
|
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||||
|
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||||
|
in mind when picking spots.
|
||||||
|
|
||||||
|
Before writing anything to TREK, do some research: look up what's worth
|
||||||
|
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||||
|
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||||
|
neighbourhood, and think about what kind of food and restaurant experiences
|
||||||
|
are worth including.
|
||||||
|
|
||||||
|
Once you have a solid plan, write the whole thing to TREK:
|
||||||
|
- Create the trip
|
||||||
|
- Add all the places you've researched with their real coordinates
|
||||||
|
- Build out the daily itinerary with sensible visiting times
|
||||||
|
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||||
|
- Add any notable restaurant reservations
|
||||||
|
- Put together a realistic budget in EUR
|
||||||
|
- Build a packing list suited to April in Kyoto
|
||||||
|
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||||
|
- Add a day note for each day with any important heads-up (early start, crowd
|
||||||
|
tips, booking requirements, etc.)
|
||||||
|
- Mark Japan as visited in my Atlas
|
||||||
|
|
||||||
|
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||||
|
of everything that was added.
|
||||||
|
```
|
||||||
|
|
||||||
|
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||||
|
|
||||||
|

|
||||||
@@ -98,7 +98,9 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||||
|
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||||
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
@@ -120,20 +122,37 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: mauriceboe/trek:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
# - OIDC_ISSUER=https://auth.example.com
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||||
# - OIDC_CLIENT_ID=trek
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - OIDC_CLIENT_SECRET=supersecret
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
# - OIDC_DISPLAY_NAME="SSO"
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
# - OIDC_ONLY=true # disable password auth entirely
|
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs)
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,6 +181,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
|||||||
|
|
||||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||||
|
|
||||||
|
### Rotating the Encryption Key
|
||||||
|
|
||||||
|
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||||
|
|
||||||
|
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||||
|
|
||||||
### Reverse Proxy (recommended)
|
### Reverse Proxy (recommended)
|
||||||
|
|
||||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||||
@@ -226,17 +257,24 @@ trek.yourdomain.com {
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
|
| **Core** | | |
|
||||||
| `PORT` | Server port | `3000` |
|
| `PORT` | Server port | `3000` |
|
||||||
| `NODE_ENV` | Environment | `production` |
|
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
| `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 |
|
||||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `OIDC_ISSUER` | OIDC provider URL | — |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
|
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||||
|
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||||
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||||
|
| **OIDC / SSO** | | |
|
||||||
|
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||||
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
|
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||||
| `OIDC_ONLY` | Disable password auth | `false` |
|
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
|
||||||
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
| **Other** | | |
|
||||||
| `DEMO_MODE` | Enable demo mode | `false` |
|
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||||
|
|
||||||
## Optional API Keys
|
## Optional API Keys
|
||||||
|
|
||||||
@@ -261,6 +299,7 @@ docker build -t trek .
|
|||||||
|
|
||||||
- **Database**: SQLite, stored in `./data/travel.db`
|
- **Database**: SQLite, stored in `./data/travel.db`
|
||||||
- **Uploads**: Stored in `./uploads/`
|
- **Uploads**: Stored in `./uploads/`
|
||||||
|
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||||
- **Backups**: Create and restore via Admin Panel
|
- **Backups**: Create and restore via Admin Panel
|
||||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
helm install trek ./chart \
|
helm install trek ./chart \
|
||||||
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
|
||||||
--set ingress.enabled=true \
|
--set ingress.enabled=true \
|
||||||
--set ingress.hosts[0].host=yourdomain.com
|
--set ingress.hosts[0].host=yourdomain.com
|
||||||
```
|
```
|
||||||
@@ -29,5 +28,7 @@ See `values.yaml` for more options.
|
|||||||
## Notes
|
## Notes
|
||||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
- PVCs require a default StorageClass or specify one as needed.
|
- PVCs require a default StorageClass or specify one as needed.
|
||||||
- JWT_SECRET must be set for production use.
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
|
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
1. JWT_SECRET handling:
|
1. ENCRYPTION_KEY handling:
|
||||||
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
|
||||||
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
|
||||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
|
||||||
|
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
|
||||||
|
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
|
||||||
|
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||||
|
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||||
|
auto-generate a key persisted to the data PVC.
|
||||||
|
|
||||||
2. Example usage:
|
2. JWT_SECRET is managed entirely by the server:
|
||||||
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
|
||||||
- Generate a random secret: `--set generateJwtSecret=true`
|
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
|
||||||
|
- No Helm configuration needed or supported.
|
||||||
|
|
||||||
|
3. Example usage:
|
||||||
|
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
|
||||||
|
- Generate a random key at install: `--set generateEncryptionKey=true`
|
||||||
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||||
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
|
||||||
|
|
||||||
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||||
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ data:
|
|||||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||||
|
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -20,21 +20,28 @@ spec:
|
|||||||
- name: {{ .name }}
|
- name: {{ .name }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: trek
|
- name: trek
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
{{- with .Values.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: {{ include "trek.fullname" . }}-config
|
name: {{ include "trek.fullname" . }}-config
|
||||||
env:
|
env:
|
||||||
- name: JWT_SECRET
|
- name: ENCRYPTION_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
|
||||||
|
optional: true
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
@@ -23,3 +24,4 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.uploads.size }}
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -7,17 +7,23 @@ metadata:
|
|||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
data:
|
data:
|
||||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||||
|
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
|
||||||
|
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "trek.fullname" . }}-secret
|
name: {{ $secretName }}
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
{{- if and $existingSecret $existingSecret.data }}
|
||||||
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
|
||||||
|
{{- else }}
|
||||||
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -16,20 +16,29 @@ env:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
# ALLOWED_ORIGINS: ""
|
# ALLOWED_ORIGINS: ""
|
||||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||||
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
|
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||||
|
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||||
|
|
||||||
|
|
||||||
# JWT secret configuration
|
# Secret environment variables stored in a Kubernetes Secret.
|
||||||
|
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
|
||||||
|
# rotatable via the admin panel) — it is not configured here.
|
||||||
secretEnv:
|
secretEnv:
|
||||||
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
|
||||||
JWT_SECRET: ""
|
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
|
||||||
|
# If left empty the server resolves the key automatically:
|
||||||
|
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
|
||||||
|
# 2. data/.encryption_key auto-generated on first start (fresh installs)
|
||||||
|
ENCRYPTION_KEY: ""
|
||||||
|
|
||||||
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||||
generateJwtSecret: false
|
generateEncryptionKey: false
|
||||||
|
|
||||||
# If set, use an existing Kubernetes secret for JWT_SECRET
|
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||||
existingSecret: ""
|
existingSecret: ""
|
||||||
existingSecretKey: JWT_SECRET
|
existingSecretKey: ENCRYPTION_KEY
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -38,7 +47,13 @@ persistence:
|
|||||||
uploads:
|
uploads:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
|
|
||||||
resources: {}
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.7.0",
|
"version": "2.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.7.0",
|
"version": "2.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
@@ -2789,9 +2789,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3693,9 +3693,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4679,9 +4679,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7181,9 +7181,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8705,9 +8705,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
|||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import { useSettingsStore } from './store/settingsStore'
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import RegisterPage from './pages/RegisterPage'
|
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import TripPlannerPage from './pages/TripPlannerPage'
|
import TripPlannerPage from './pages/TripPlannerPage'
|
||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
@@ -14,8 +13,8 @@ import AtlasPage from './pages/AtlasPage'
|
|||||||
import SharedTripPage from './pages/SharedTripPage'
|
import SharedTripPage from './pages/SharedTripPage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider, useTranslation } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
|
||||||
import { authApi } from './api/client'
|
import { authApi } from './api/client'
|
||||||
|
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -23,8 +22,12 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const isLoading = useAuthStore((s) => s.isLoading)
|
||||||
|
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +44,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
|||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appRequireMfa &&
|
||||||
|
user &&
|
||||||
|
!user.mfa_enabled &&
|
||||||
|
location.pathname !== '/settings'
|
||||||
|
) {
|
||||||
|
return <Navigate to="/settings?mfa=required" replace />
|
||||||
|
}
|
||||||
|
|
||||||
if (adminRequired && user && user.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
@@ -63,17 +75,18 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
const { loadUser, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
loadUser()
|
||||||
loadUser()
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
}
|
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
if (config?.timezone) setServerTimezone(config.timezone)
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||||
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||||
|
|
||||||
if (config?.version) {
|
if (config?.version) {
|
||||||
const storedVersion = localStorage.getItem('trek_app_version')
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
@@ -105,7 +118,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Shared page always forces light mode
|
||||||
|
if (isSharedPage) {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', '#ffffff')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const mode = settings.dark_mode
|
const mode = settings.dark_mode
|
||||||
const applyDark = (isDark: boolean) => {
|
const applyDark = (isDark: boolean) => {
|
||||||
document.documentElement.classList.toggle('dark', isDark)
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
@@ -121,7 +145,7 @@ export default function App() {
|
|||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}
|
}
|
||||||
applyDark(mode === true || mode === 'dark')
|
applyDark(mode === true || mode === 'dark')
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode, isSharedPage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
|
||||||
|
if (!url) return url
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/resource-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ purpose }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) return url
|
||||||
|
const { token } = await resp.json()
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
|
|||||||
|
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor - add auth token and socket ID
|
// Request interceptor - add socket ID
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
@@ -29,11 +26,17 @@ apiClient.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('auth_token')
|
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
|
!window.location.pathname.startsWith('/settings')
|
||||||
|
) {
|
||||||
|
window.location.href = '/settings?mfa=required'
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -44,7 +47,7 @@ export const authApi = {
|
|||||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||||
@@ -61,6 +64,11 @@ export const authApi = {
|
|||||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
|
mcpTokens: {
|
||||||
|
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||||
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||||
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tripsApi = {
|
export const tripsApi = {
|
||||||
@@ -95,6 +103,8 @@ export const placesApi = {
|
|||||||
const fd = new FormData(); fd.append('file', file)
|
const fd = new FormData(); fd.append('file', file)
|
||||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
@@ -151,7 +161,6 @@ export const adminApi = {
|
|||||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
|
||||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||||
@@ -170,6 +179,11 @@ export const adminApi = {
|
|||||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||||
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||||
|
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||||
|
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||||
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
@@ -267,9 +281,8 @@ export const backupApi = {
|
|||||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||||
download: async (filename: string): Promise<void> => {
|
download: async (filename: string): Promise<void> => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Download failed')
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -302,6 +315,7 @@ export const notificationsApi = {
|
|||||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
|
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
|
|||||||
const MAX_RECONNECT_DELAY = 30000
|
const MAX_RECONNECT_DELAY = 30000
|
||||||
const listeners = new Set<WebSocketListener>()
|
const listeners = new Set<WebSocketListener>()
|
||||||
const activeTrips = new Set<string>()
|
const activeTrips = new Set<string>()
|
||||||
let currentToken: string | null = null
|
let shouldReconnect = false
|
||||||
let refetchCallback: RefetchCallback | null = null
|
let refetchCallback: RefetchCallback | null = null
|
||||||
let mySocketId: string | null = null
|
let mySocketId: string | null = null
|
||||||
|
let connecting = false
|
||||||
|
|
||||||
export function getSocketId(): string | null {
|
export function getSocketId(): string | null {
|
||||||
return mySocketId
|
return mySocketId
|
||||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
|||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(token: string): string {
|
function getWsUrl(wsToken: string): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
return `${protocol}://${location.host}/ws?token=${token}`
|
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/ws-token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (resp.status === 401) {
|
||||||
|
// Session expired — stop reconnecting
|
||||||
|
shouldReconnect = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const { token } = await resp.json()
|
||||||
|
return token as string
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent): void {
|
function handleMessage(event: MessageEvent): void {
|
||||||
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
|
|||||||
if (reconnectTimer) return
|
if (reconnectTimer) return
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
connectInternal(currentToken, true)
|
connectInternal(true)
|
||||||
}
|
}
|
||||||
}, reconnectDelay)
|
}, reconnectDelay)
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectInternal(token: string, _isReconnect = false): void {
|
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||||
|
if (connecting) return
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getWsUrl(token)
|
connecting = true
|
||||||
|
const wsToken = await fetchWsToken()
|
||||||
|
connecting = false
|
||||||
|
|
||||||
|
if (!wsToken) {
|
||||||
|
if (shouldReconnect) scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getWsUrl(wsToken)
|
||||||
socket = new WebSocket(url)
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
socket = null
|
socket = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connect(token: string): void {
|
export function connect(): void {
|
||||||
currentToken = token
|
shouldReconnect = true
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
}
|
}
|
||||||
connectInternal(token, false)
|
connectInternal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnect(): void {
|
export function disconnect(): void {
|
||||||
currentToken = null
|
shouldReconnect = false
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -32,6 +33,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||||
const [addons, setAddons] = useState([])
|
const [addons, setAddons] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||||
try {
|
try {
|
||||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||||
window.dispatchEvent(new Event('addons-changed'))
|
refreshGlobalAddons()
|
||||||
toast.success(t('admin.addons.toast.updated'))
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Rollback
|
// Rollback
|
||||||
@@ -68,6 +70,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
|
|
||||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||||
const globalAddons = addons.filter(a => a.type === 'global')
|
const globalAddons = addons.filter(a => a.type === 'global')
|
||||||
|
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -144,6 +147,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Integration Addons */}
|
||||||
|
{integrationAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{integrationAddons.map(addon => (
|
||||||
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,11 +206,8 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
|||||||
Coming Soon
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
interface AdminMcpToken {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
created_at: string
|
||||||
|
last_used_at: string | null
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminMcpTokensPanel() {
|
||||||
|
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
adminApi.mcpTokens()
|
||||||
|
.then(d => setTokens(d.tokens || []))
|
||||||
|
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteMcpToken(id)
|
||||||
|
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.mcpTokens.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||||
|
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||||
|
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||||
|
<span>{t('admin.mcpTokens.owner')}</span>
|
||||||
|
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||||
|
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
{tokens.map((token, i) => (
|
||||||
|
<div key={token.id}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||||
|
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="whitespace-nowrap">{token.username}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
<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)}
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirmId !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||||
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setDeleteConfirmId(null)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,7 +15,11 @@ interface AuditEntry {
|
|||||||
ip: string | null
|
ip: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPanel(): React.ReactElement {
|
interface AuditLogPanelProps {
|
||||||
|
serverTimezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
|
|||||||
|
|
||||||
const fmtTime = (iso: string) => {
|
const fmtTime = (iso: string) => {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString(locale, {
|
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
timeStyle: 'medium',
|
timeStyle: 'medium',
|
||||||
|
timeZone: serverTimezone || undefined,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return iso
|
return iso
|
||||||
|
|||||||
@@ -324,9 +324,11 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
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)' }}
|
||||||
>
|
>
|
||||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
const inlineFormat = (text) => {
|
const inlineFormat = (text) => {
|
||||||
return text
|
return escapeHtml(text)
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||||
|
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||||
|
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
|
interface PermissionEntry {
|
||||||
|
key: string
|
||||||
|
level: PermissionLevel
|
||||||
|
defaultLevel: PermissionLevel
|
||||||
|
allowedLevels: PermissionLevel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
admin: 'perm.level.admin',
|
||||||
|
trip_owner: 'perm.level.tripOwner',
|
||||||
|
trip_member: 'perm.level.tripMember',
|
||||||
|
everybody: 'perm.level.everybody',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||||
|
{ id: 'members', keys: ['member_manage'] },
|
||||||
|
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||||
|
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||||
|
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PermissionsPanel(): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||||
|
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPermissions()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPermissions = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getPermissions()
|
||||||
|
setEntries(data.permissions)
|
||||||
|
const vals: Record<string, PermissionLevel> = {}
|
||||||
|
for (const p of data.permissions) vals[p.key] = p.level
|
||||||
|
setValues(vals)
|
||||||
|
setDirty(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (key: string, level: PermissionLevel) => {
|
||||||
|
setValues(prev => ({ ...prev, [key]: level }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.updatePermissions(values)
|
||||||
|
if (data.permissions) {
|
||||||
|
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
toast.success(t('perm.saved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const defaults: Record<string, PermissionLevel> = {}
|
||||||
|
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||||
|
setValues(defaults)
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
{t('perm.resetDefaults')}
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<div key={cat.id} className="px-6 py-4">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
{t(`perm.cat.${cat.id}`)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cat.keys.map(key => {
|
||||||
|
const entry = entryMap.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
const currentLevel = values[key] || entry.defaultLevel
|
||||||
|
const isDefault = currentLevel === entry.defaultLevel
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isDefault && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||||
|
{t('perm.customized')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CustomSelect
|
||||||
|
value={currentLevel}
|
||||||
|
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||||
|
options={entry.allowedLevels.map(l => ({
|
||||||
|
value: l,
|
||||||
|
label: t(LEVEL_LABELS[l] || l),
|
||||||
|
}))}
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { budgetApi } from '../../api/client'
|
import { budgetApi } from '../../api/client'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import type { BudgetItem, BudgetMember } from '../../types'
|
import type { BudgetItem, BudgetMember } from '../../types'
|
||||||
import { currencyDecimals } from '../../utils/formatters'
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
|
|||||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||||
|
|
||||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editValue, setEditValue] = useState(value ?? '')
|
const [editValue, setEditValue] = useState(value ?? '')
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
@@ -86,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
|||||||
: (value || '')
|
: (value || '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
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',
|
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||||
{display || placeholder || '-'}
|
{display || placeholder || '-'}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -99,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
|||||||
|
|
||||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||||
interface AddItemRowProps {
|
interface AddItemRowProps {
|
||||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
|||||||
const [persons, setPersons] = useState('')
|
const [persons, setPersons] = useState('')
|
||||||
const [days, setDays] = useState('')
|
const [days, setDays] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const [expenseDate, setExpenseDate] = useState('')
|
||||||
const nameRef = useRef(null)
|
const nameRef = useRef(null)
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!name.trim()) return
|
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 })
|
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 })
|
||||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||||
setTimeout(() => nameRef.current?.focus(), 50)
|
setTimeout(() => nameRef.current?.focus(), 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||||
|
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||||
</td>
|
</td>
|
||||||
@@ -227,9 +235,10 @@ interface BudgetMemberChipsProps {
|
|||||||
onSetMembers: (memberIds: number[]) => void
|
onSetMembers: (memberIds: number[]) => void
|
||||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
|
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||||
const chipSize = compact ? 20 : 30
|
const chipSize = compact ? 20 : 30
|
||||||
const btnSize = compact ? 18 : 28
|
const btnSize = compact ? 18 : 28
|
||||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||||
@@ -271,17 +280,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
|
|||||||
{members.map(m => (
|
{members.map(m => (
|
||||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||||
paid={!!m.paid}
|
paid={!!m.paid}
|
||||||
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button ref={btnRef} onClick={openDropdown}
|
{!readOnly && (
|
||||||
style={{
|
<button ref={btnRef} onClick={openDropdown}
|
||||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
style={{
|
||||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
}}>
|
||||||
</button>
|
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showDropdown && ReactDOM.createPortal(
|
{showDropdown && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||||
@@ -412,12 +423,14 @@ interface BudgetPanelProps {
|
|||||||
|
|
||||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||||
|
const can = useCanDo()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
const canEdit = can('budget_edit', trip)
|
||||||
|
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||||
const hasMultipleMembers = tripMembers.length > 1
|
const hasMultipleMembers = tripMembers.length > 1
|
||||||
@@ -470,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
setNewCategoryName('')
|
setNewCategoryName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const sep = ';'
|
||||||
|
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||||
|
const d = currencyDecimals(currency)
|
||||||
|
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) }
|
||||||
|
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||||
|
const rows = [header.join(sep)]
|
||||||
|
|
||||||
|
for (const cat of categoryNames) {
|
||||||
|
for (const item of (grouped[cat] || [])) {
|
||||||
|
const pp = calcPP(item.total_price, item.persons)
|
||||||
|
const pd = calcPD(item.total_price, item.days)
|
||||||
|
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||||
|
rows.push([
|
||||||
|
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||||
|
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||||
|
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||||
|
esc(item.note || ''),
|
||||||
|
].join(sep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bom = '\uFEFF'
|
||||||
|
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
|
||||||
|
a.download = `budget-${safeName}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
@@ -482,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
{canEdit && (
|
||||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||||
placeholder={t('budget.emptyPlaceholder')}
|
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
placeholder={t('budget.emptyPlaceholder')}
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||||
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 }}>
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
<Plus size={16} />
|
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 }}>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<Calculator size={20} color="var(--text-primary)" />
|
<Calculator size={20} color="var(--text-primary)" />
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
@@ -518,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||||
{editingCat?.name === cat ? (
|
{canEdit && editingCat?.name === cat ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={editingCat.value}
|
value={editingCat.value}
|
||||||
@@ -530,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
{canEdit && (
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||||
<Pencil size={10} />
|
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||||
</button>
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
{canEdit && (
|
||||||
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 }}
|
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
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 }}
|
||||||
<Trash2 size={13} />
|
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||||
</button>
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -552,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||||
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||||
<th style={{ ...th, width: 36 }}></th>
|
<th style={{ ...th, width: 36 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -574,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
<td style={td}>
|
<td style={td}>
|
||||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||||
{hasMultipleMembers && (
|
{hasMultipleMembers && (
|
||||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||||
@@ -584,12 +643,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
compact={false}
|
compact={false}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...td, textAlign: 'center' }}>
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||||
{hasMultipleMembers ? (
|
{hasMultipleMembers ? (
|
||||||
@@ -598,29 +658,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
<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) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
</td>
|
</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: 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>
|
<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>
|
||||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||||
<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')} /></td>
|
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||||
|
{canEdit ? (
|
||||||
|
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||||
|
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<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' }}>
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
|
{canEdit && (
|
||||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
<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' }}
|
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'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
<div className="w-full md:w-[180px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
|
disabled={!canEdit}
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
{canEdit && (
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||||
value={newCategoryName}
|
<input
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
value={newCategoryName}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
placeholder={t('budget.categoryName')}
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
placeholder={t('budget.categoryName')}
|
||||||
/>
|
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
/>
|
||||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
<Plus size={16} />
|
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||||
@@ -666,7 +741,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
|||||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
|||||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
|
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onMouseEnter={() => setHoveredId(msg.id)}
|
onMouseEnter={() => setHoveredId(msg.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||||
onTouchEnd={e => {
|
onTouchEnd={e => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||||
if (now - lastTap < 300) {
|
if (now - lastTap < 300 && canEdit) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const touch = e.changedTouches?.[0]
|
const touch = e.changedTouches?.[0]
|
||||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||||
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
transition: 'opacity .1s',
|
transition: 'opacity .1s',
|
||||||
...(own ? { left: -6 } : { right: -6 }),
|
...(own ? { left: -6 } : { right: -6 }),
|
||||||
}}>
|
}}>
|
||||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
>
|
>
|
||||||
<Reply size={11} />
|
<Reply size={11} />
|
||||||
</button>
|
</button>
|
||||||
{own && (
|
{own && canEdit && (
|
||||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
{msg.reactions.map(r => {
|
{msg.reactions.map(r => {
|
||||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||||
return (
|
return (
|
||||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||||
{/* Emoji button */}
|
{/* Emoji button */}
|
||||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||||
<Smile size={20} />
|
}}>
|
||||||
</button>
|
<Smile size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
disabled={!canEdit}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||||
maxHeight: 100, overflowY: 'hidden',
|
maxHeight: 100, overflowY: 'hidden',
|
||||||
|
opacity: canEdit ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
placeholder={t('collab.chat.placeholder')}
|
placeholder={t('collab.chat.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send */}
|
{/* Send */}
|
||||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
transition: 'background 0.15s',
|
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||||
}}>
|
transition: 'background 0.15s',
|
||||||
<ArrowUp size={18} strokeWidth={2.5} />
|
}}>
|
||||||
</button>
|
<ArrowUp size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Markdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -216,7 +218,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
|||||||
interface NoteFormModalProps {
|
interface NoteFormModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||||
existingCategories: string[]
|
existingCategories: string[]
|
||||||
categoryColors: Record<string, string>
|
categoryColors: Record<string, string>
|
||||||
getCategoryColor: (category: string) => string
|
getCategoryColor: (category: string) => string
|
||||||
@@ -226,6 +228,9 @@ interface NoteFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canUploadFiles = can('file_upload', tripObj)
|
||||||
const isEdit = !!note
|
const isEdit = !!note
|
||||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||||
|
|
||||||
@@ -298,6 +303,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -450,7 +456,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File attachments */}
|
{/* File attachments */}
|
||||||
<div>
|
{canUploadFiles && <div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||||
{t('collab.notes.attachFiles')}
|
{t('collab.notes.attachFiles')}
|
||||||
</div>
|
</div>
|
||||||
@@ -483,7 +489,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
@@ -689,6 +695,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
|||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: CollabNote
|
note: CollabNote
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||||
onDelete: (noteId: number) => Promise<void>
|
onDelete: (noteId: number) => Promise<void>
|
||||||
onEdit: (note: CollabNote) => void
|
onEdit: (note: CollabNote) => void
|
||||||
@@ -699,7 +706,7 @@ interface NoteCardProps {
|
|||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||||
@@ -760,24 +767,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
|||||||
<Maximize2 size={10} />
|
<Maximize2 size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
{canEdit && <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' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={10} />
|
<Trash2 size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||||
{/* Author avatar */}
|
{/* Author avatar */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||||
@@ -879,6 +886,9 @@ interface CollabNotesProps {
|
|||||||
|
|
||||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [notes, setNotes] = useState([])
|
const [notes, setNotes] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showNewModal, setShowNewModal] = useState(false)
|
const [showNewModal, setShowNewModal] = useState(false)
|
||||||
@@ -1124,17 +1134,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
{t('collab.notes.title')}
|
{t('collab.notes.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||||
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' }}
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => setShowNewModal(true)}
|
{canEdit && <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: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
{t('collab.notes.new')}
|
{t('collab.notes.new')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1252,6 +1262,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
canEdit={canEdit}
|
||||||
onUpdate={handleUpdateNote}
|
onUpdate={handleUpdateNote}
|
||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
onEdit={setEditingNote}
|
onEdit={setEditingNote}
|
||||||
@@ -1303,12 +1314,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => setViewingNote(null)}
|
<button onClick={() => setViewingNote(null)}
|
||||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -1327,6 +1338,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
|
|
||||||
{showNewModal && (
|
{showNewModal && (
|
||||||
<NoteFormModal
|
<NoteFormModal
|
||||||
|
note={null}
|
||||||
|
tripId={tripId}
|
||||||
onClose={() => setShowNewModal(false)}
|
onClose={() => setShowNewModal(false)}
|
||||||
onSubmit={handleCreateNote}
|
onSubmit={handleCreateNote}
|
||||||
existingCategories={categories}
|
existingCategories={categories}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
|||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
|
||||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
|||||||
interface PollCardProps {
|
interface PollCardProps {
|
||||||
poll: Poll
|
poll: Poll
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||||
onClose: (pollId: number) => Promise<void>
|
onClose: (pollId: number) => Promise<void>
|
||||||
onDelete: (pollId: number) => Promise<void>
|
onDelete: (pollId: number) => Promise<void>
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||||
const total = totalVotes(poll)
|
const total = totalVotes(poll)
|
||||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||||
const remaining = timeRemaining(poll.deadline)
|
const remaining = timeRemaining(poll.deadline)
|
||||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
{canEdit && (
|
||||||
{!isClosed && (
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
{!isClosed && (
|
||||||
|
<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')}
|
||||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Lock size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<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)'}>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
|||||||
|
|
||||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [polls, setPolls] = useState([])
|
const [polls, setPolls] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
<BarChart3 size={14} color="var(--text-faint)" />
|
<BarChart3 size={14} color="var(--text-faint)" />
|
||||||
{t('collab.polls.title')}
|
{t('collab.polls.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowForm(true)} style={{
|
{canEdit && (
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
<button onClick={() => setShowForm(true)} style={{
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||||
}}>
|
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||||
<Plus size={12} /> {t('collab.polls.new')}
|
}}>
|
||||||
</button>
|
<Plus size={12} /> {t('collab.polls.new')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{activePolls.length > 0 && activePolls.map(poll => (
|
{activePolls.length > 0 && activePolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
{closedPolls.length > 0 && (
|
{closedPolls.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{closedPolls.map(poll => (
|
{closedPolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { useTranslation } from '../../i18n'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||||
|
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||||
|
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||||
|
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||||
|
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||||
|
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||||
|
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||||
]
|
]
|
||||||
|
|
||||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { filesApi } from '../../api/client'
|
import { filesApi } from '../../api/client'
|
||||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
|
||||||
function isImage(mimeType) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
@@ -41,6 +45,10 @@ interface ImageLightboxProps {
|
|||||||
|
|
||||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||||
|
}, [file.url])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
@@ -48,16 +56,20 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<img
|
<img
|
||||||
src={file.url}
|
src={imgSrc}
|
||||||
alt={file.original_name}
|
alt={file.original_name}
|
||||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
<button
|
||||||
|
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}
|
||||||
|
title={t('files.openTab')}
|
||||||
|
>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
</a>
|
</button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -68,6 +80,15 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticated image — fetches a short-lived download token and renders the image
|
||||||
|
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||||
|
const [authSrc, setAuthSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||||
|
}, [src])
|
||||||
|
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// Source badge
|
// Source badge
|
||||||
interface SourceBadgeProps {
|
interface SourceBadgeProps {
|
||||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||||
@@ -153,6 +174,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
const loadTrash = useCallback(async () => {
|
const loadTrash = useCallback(async () => {
|
||||||
@@ -247,6 +270,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
|
if (!can('file_upload', trip)) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
const pastedFiles = []
|
const pastedFiles = []
|
||||||
@@ -281,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [previewFile, setPreviewFile] = useState(null)
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
|
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewFile) {
|
||||||
|
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||||
|
} else {
|
||||||
|
setPreviewFileUrl('')
|
||||||
|
}
|
||||||
|
}, [previewFile?.url])
|
||||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||||
@@ -311,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={file.id} style={{
|
<div key={file.id} style={{
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
@@ -326,7 +356,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
>
|
>
|
||||||
{/* Icon or thumbnail */}
|
{/* Icon or thumbnail */}
|
||||||
<div
|
<div
|
||||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
onClick={() => !isTrash && openFile(file)}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -334,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isImage(file.mime_type)
|
{isImage(file.mime_type)
|
||||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
: (() => {
|
: (() => {
|
||||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
@@ -355,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
)}
|
)}
|
||||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
<span
|
<span
|
||||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
onClick={() => !isTrash && openFile(file)}
|
||||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
{file.original_name}
|
{file.original_name}
|
||||||
@@ -386,14 +416,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
{isTrash ? (
|
{isTrash ? (
|
||||||
<>
|
<>
|
||||||
<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 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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<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 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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -401,18 +431,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
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'} />
|
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
<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 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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
<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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
<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 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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -622,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, 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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
<button
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
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')}
|
<ExternalLink size={13} /> {t('files.openTab')}
|
||||||
</a>
|
</button>
|
||||||
<button onClick={() => setPreviewFile(null)}
|
<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' }}
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -637,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<object
|
<object
|
||||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,7 +706,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
{showTrash ? (
|
{showTrash ? (
|
||||||
/* Trash view */
|
/* Trash view */
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
{trashFiles.length > 0 && (
|
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
<button onClick={handleEmptyTrash} style={{
|
<button onClick={handleEmptyTrash} style={{
|
||||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||||
@@ -704,7 +735,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Upload zone */}
|
{/* Upload zone */}
|
||||||
<div
|
{can('file_upload', trip) && <div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
style={{
|
style={{
|
||||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
@@ -729,7 +760,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* Filter tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import ReactDOM from 'react-dom'
|
|||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { addonsApi } from '../../api/client'
|
|
||||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -28,29 +28,21 @@ interface Addon {
|
|||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
|
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
|
||||||
const darkMode = settings.dark_mode
|
const darkMode = settings.dark_mode
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
const loadAddons = () => {
|
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||||
if (user) {
|
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||||
addonsApi.enabled().then(data => {
|
|
||||||
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEffect(loadAddons, [user, location.pathname])
|
|
||||||
// Listen for addon changes from AddonManager
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => loadAddons()
|
if (user) loadAddons()
|
||||||
window.addEventListener('addons-changed', handler)
|
}, [user, location.pathname])
|
||||||
return () => window.removeEventListener('addons-changed', handler)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('../../api/client').then(({ authApi }) => {
|
import('../../api/client').then(({ authApi }) => {
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
|
|
||||||
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
|
try {
|
||||||
|
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
// Fix default marker icons for vite
|
// Fix default marker icons for vite
|
||||||
@@ -26,7 +34,12 @@ function escAttr(s) {
|
|||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconCache = new Map<string, L.DivIcon>()
|
||||||
|
|
||||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||||
|
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||||
|
const cached = iconCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
const size = isSelected ? 44 : 36
|
const size = isSelected ? 44 : 36
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
const borderWidth = isSelected ? 3 : 2.5
|
const borderWidth = isSelected ? 3 : 2.5
|
||||||
@@ -34,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||||
const bgColor = place.category_color || '#6b7280'
|
const bgColor = place.category_color || '#6b7280'
|
||||||
const icon = place.category_icon || '📍'
|
|
||||||
|
|
||||||
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
// Number badges (bottom-right)
|
||||||
let badgeHtml = ''
|
let badgeHtml = ''
|
||||||
if (orderNumbers && orderNumbers.length > 0) {
|
if (orderNumbers && orderNumbers.length > 0) {
|
||||||
const label = orderNumbers.join(' · ')
|
const label = orderNumbers.join(' · ')
|
||||||
@@ -54,28 +66,30 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (place.image_url) {
|
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||||
return L.divIcon({
|
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||||
|
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||||
|
const imgIcon = L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
border:${borderWidth}px solid ${borderColor};
|
border:${borderWidth}px solid ${borderColor};
|
||||||
box-shadow:${shadow};
|
box-shadow:${shadow};
|
||||||
overflow:visible;background:${bgColor};
|
overflow:hidden;background:${bgColor};
|
||||||
cursor:pointer;flex-shrink:0;position:relative;
|
cursor:pointer;position:relative;
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||||
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
|
||||||
</div>
|
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
})
|
})
|
||||||
|
iconCache.set(cacheKey, imgIcon)
|
||||||
|
return imgIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
return L.divIcon({
|
const fallbackIcon = L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
@@ -84,14 +98,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
background:${bgColor};
|
background:${bgColor};
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
cursor:pointer;position:relative;
|
cursor:pointer;position:relative;
|
||||||
|
will-change:transform;contain:layout style;
|
||||||
">
|
">
|
||||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
})
|
})
|
||||||
|
iconCache.set(cacheKey, fallbackIcon)
|
||||||
|
return fallbackIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectionControllerProps {
|
interface SelectionControllerProps {
|
||||||
@@ -166,6 +183,16 @@ interface MapClickHandlerProps {
|
|||||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
map.on('zoomstart', onZoomStart)
|
||||||
|
map.on('zoomend', onZoomEnd)
|
||||||
|
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||||
|
}, [map, onZoomStart, onZoomEnd])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -237,8 +264,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
const mapPhotoCache = new Map()
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
const mapPhotoInFlight = new Set()
|
|
||||||
|
|
||||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||||
function LocationTracker() {
|
function LocationTracker() {
|
||||||
@@ -330,7 +356,7 @@ function LocationTracker() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({
|
export const MapView = memo(function MapView({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
@@ -358,54 +384,110 @@ export function MapView({
|
|||||||
const right = rightWidth + 40
|
const right = rightWidth + 40
|
||||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||||
}, [leftWidth, rightWidth, hasInspector])
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
|
||||||
|
|
||||||
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
// photoUrls: only base64 thumbs for smooth map zoom
|
||||||
|
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||||
|
|
||||||
|
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||||
|
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const queue = places.filter(place => {
|
if (!places || places.length === 0) return
|
||||||
if (place.image_url) return false
|
const cleanups: (() => void)[] = []
|
||||||
|
|
||||||
|
const setThumb = (cacheKey: string, thumb: string) => {
|
||||||
|
iconCache.clear()
|
||||||
|
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const place of places) {
|
||||||
|
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
if (!cacheKey) return false
|
if (!cacheKey) continue
|
||||||
if (mapPhotoCache.has(cacheKey)) {
|
|
||||||
const cached = mapPhotoCache.get(cacheKey)
|
const cached = getCached(cacheKey)
|
||||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
if (cached?.thumbDataUrl) {
|
||||||
return false
|
setThumb(cacheKey, cached.thumbDataUrl)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if (mapPhotoInFlight.has(cacheKey)) return false
|
|
||||||
const photoId = place.google_place_id || place.osm_id
|
|
||||||
if (!photoId && !(place.lat && place.lng)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
let active = 0
|
// Subscribe for when thumb becomes available
|
||||||
const MAX_CONCURRENT = 3
|
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||||
let idx = 0
|
|
||||||
|
|
||||||
const fetchNext = () => {
|
// Always fetch through API — returns fresh URL + converts to base64
|
||||||
while (active < MAX_CONCURRENT && idx < queue.length) {
|
if (!cached && !isLoading(cacheKey)) {
|
||||||
const place = queue[idx++]
|
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
|
||||||
const photoId = place.google_place_id || place.osm_id
|
const photoId = place.google_place_id || place.osm_id
|
||||||
mapPhotoInFlight.add(cacheKey)
|
if (photoId || (place.lat && place.lng)) {
|
||||||
active++
|
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
}
|
||||||
.then(data => {
|
|
||||||
if (data.photoUrl) {
|
|
||||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
|
||||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
|
||||||
} else {
|
|
||||||
mapPhotoCache.set(cacheKey, null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
|
||||||
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchNext()
|
|
||||||
}, [places])
|
return () => cleanups.forEach(fn => fn())
|
||||||
|
}, [placeIds])
|
||||||
|
|
||||||
|
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||||
|
className: 'marker-cluster-wrapper',
|
||||||
|
iconSize: L.point(size, size),
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||||
|
|
||||||
|
const markers = useMemo(() => places.map((place) => {
|
||||||
|
const isSelected = place.id === selectedPlaceId
|
||||||
|
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||||
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||||
|
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={place.id}
|
||||||
|
position={[place.lat, place.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||||
|
}}
|
||||||
|
zIndexOffset={isSelected ? 1000 : 0}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
direction="right"
|
||||||
|
offset={[0, 0]}
|
||||||
|
opacity={1}
|
||||||
|
className="map-tooltip"
|
||||||
|
permanent={isTouchDevice && isSelected}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||||
|
{place.name}
|
||||||
|
</div>
|
||||||
|
{place.category_name && (() => {
|
||||||
|
const CatIcon = getCategoryIcon(place.category_icon)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||||
|
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{place.address && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{place.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
|
id="trek-map"
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
@@ -416,6 +498,10 @@ export function MapView({
|
|||||||
url={tileUrl}
|
url={tileUrl}
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
maxZoom={19}
|
maxZoom={19}
|
||||||
|
keepBuffer={8}
|
||||||
|
updateWhenZooming={false}
|
||||||
|
updateWhenIdle={true}
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapController center={center} zoom={zoom} />
|
<MapController center={center} zoom={zoom} />
|
||||||
@@ -427,71 +513,17 @@ export function MapView({
|
|||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
|
chunkInterval={30}
|
||||||
|
chunkDelay={0}
|
||||||
maxClusterRadius={30}
|
maxClusterRadius={30}
|
||||||
disableClusteringAtZoom={11}
|
disableClusteringAtZoom={11}
|
||||||
spiderfyOnMaxZoom
|
spiderfyOnMaxZoom
|
||||||
showCoverageOnHover={false}
|
showCoverageOnHover={false}
|
||||||
zoomToBoundsOnClick
|
zoomToBoundsOnClick
|
||||||
singleMarkerMode
|
animate={false}
|
||||||
iconCreateFunction={(cluster) => {
|
iconCreateFunction={clusterIconCreateFunction}
|
||||||
const count = cluster.getChildCount()
|
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
|
||||||
return L.divIcon({
|
|
||||||
html: `<div class="marker-cluster-custom"
|
|
||||||
style="width:${size}px;height:${size}px;">
|
|
||||||
<span>${count}</span>
|
|
||||||
</div>`,
|
|
||||||
className: 'marker-cluster-wrapper',
|
|
||||||
iconSize: L.point(size, size),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{places.map((place) => {
|
{markers}
|
||||||
const isSelected = place.id === selectedPlaceId
|
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
|
||||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
|
||||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
|
||||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={place.id}
|
|
||||||
position={[place.lat, place.lng]}
|
|
||||||
icon={icon}
|
|
||||||
eventHandlers={{
|
|
||||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
|
||||||
}}
|
|
||||||
zIndexOffset={isSelected ? 1000 : 0}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
direction="right"
|
|
||||||
offset={[0, 0]}
|
|
||||||
opacity={1}
|
|
||||||
className="map-tooltip"
|
|
||||||
>
|
|
||||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
|
||||||
{place.name}
|
|
||||||
</div>
|
|
||||||
{place.category_name && (() => {
|
|
||||||
const CatIcon = getCategoryIcon(place.category_icon)
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
|
||||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{place.address && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{place.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 1 && (
|
{route && route.length > 1 && (
|
||||||
@@ -508,6 +540,24 @@ export function MapView({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* GPX imported route geometries */}
|
||||||
|
{places.map((place) => {
|
||||||
|
if (!place.route_geometry) return null
|
||||||
|
try {
|
||||||
|
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||||
|
if (!coords || coords.length < 2) return null
|
||||||
|
return (
|
||||||
|
<Polyline
|
||||||
|
key={`gpx-${place.id}`}
|
||||||
|
positions={coords}
|
||||||
|
color={place.category_color || '#3b82f6'}
|
||||||
|
weight={3.5}
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} catch { return null }
|
||||||
|
})}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function calculateRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||||
|
|
||||||
const response = await fetch(url, { signal })
|
const response = await fetch(url, { signal })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
|
||||||
|
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(baseUrl, 'immich').then(setSrc)
|
||||||
|
}, [baseUrl])
|
||||||
|
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,11 +61,65 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
const [sortAsc, setSortAsc] = useState(true)
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
const [locationFilter, setLocationFilter] = useState('')
|
const [locationFilter, setLocationFilter] = useState('')
|
||||||
|
|
||||||
|
// Album linking
|
||||||
|
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||||
|
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||||
|
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||||
|
const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||||
|
const [syncing, setSyncing] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const loadAlbumLinks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||||
|
setAlbumLinks(res.data.links || [])
|
||||||
|
} catch { setAlbumLinks([]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAlbumPicker = async () => {
|
||||||
|
setShowAlbumPicker(true)
|
||||||
|
setAlbumsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/integrations/immich/albums')
|
||||||
|
setAlbums(res.data.albums || [])
|
||||||
|
} catch { setAlbums([]) }
|
||||||
|
finally { setAlbumsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
|
||||||
|
setShowAlbumPicker(false)
|
||||||
|
await loadAlbumLinks()
|
||||||
|
// Auto-sync after linking
|
||||||
|
const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||||
|
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
||||||
|
if (newLink) await syncAlbum(newLink.id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkAlbum = async (linkId: number) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
||||||
|
loadAlbumLinks()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncAlbum = async (linkId: number) => {
|
||||||
|
setSyncing(linkId)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
|
||||||
|
await loadAlbumLinks()
|
||||||
|
await loadPhotos()
|
||||||
|
} catch {}
|
||||||
|
finally { setSyncing(null) }
|
||||||
|
}
|
||||||
|
|
||||||
// Lightbox
|
// Lightbox
|
||||||
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -89,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setConnected(false)
|
setConnected(false)
|
||||||
}
|
}
|
||||||
await loadPhotos()
|
await loadPhotos()
|
||||||
|
await loadAlbumLinks()
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,13 +231,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const token = useAuthStore(s => s.token)
|
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||||
const thumbnailUrl = (assetId: string, userId: number) =>
|
|
||||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
|
||||||
|
|
||||||
const originalUrl = (assetId: string, userId: number) =>
|
|
||||||
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
|
||||||
|
|
||||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
@@ -224,6 +283,72 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Album Picker Modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showAlbumPicker) {
|
||||||
|
const linkedIds = new Set(albumLinks.map(l => l.immich_album_id))
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectAlbum')}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setShowAlbumPicker(false)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{albumsLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 24, height: 24, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
) : albums.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', padding: 40, fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
|
{t('memories.noAlbums')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{albums.map(album => {
|
||||||
|
const isLinked = linkedIds.has(album.id)
|
||||||
|
return (
|
||||||
|
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||||
|
disabled={isLinked}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||||
|
borderRadius: 10, border: 'none', cursor: isLinked ? 'default' : 'pointer',
|
||||||
|
background: isLinked ? 'var(--bg-tertiary)' : 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||||
|
opacity: isLinked ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isLinked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!isLinked) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<FolderOpen size={20} color="var(--text-muted)" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{album.albumName}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||||
|
{album.assetCount} {t('memories.photos')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLinked ? (
|
||||||
|
<Check size={16} color="var(--text-faint)" />
|
||||||
|
) : (
|
||||||
|
<Link2 size={16} color="var(--text-muted)" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (showPicker) {
|
if (showPicker) {
|
||||||
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||||
|
|
||||||
@@ -328,7 +453,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
outlineOffset: -3,
|
outlineOffset: -3,
|
||||||
}}>
|
}}>
|
||||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -404,16 +529,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{connected && (
|
{connected && (
|
||||||
<button onClick={openPicker}
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
style={{
|
<button onClick={openAlbumPicker}
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
style={{
|
||||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
|
||||||
}}>
|
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<Plus size={14} /> {t('memories.addPhotos')}
|
}}>
|
||||||
</button>
|
<Link2 size={13} /> {t('memories.linkAlbum')}
|
||||||
|
</button>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked Albums */}
|
||||||
|
{albumLinks.length > 0 && (
|
||||||
|
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{albumLinks.map(link => (
|
||||||
|
<div key={link.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 8,
|
||||||
|
background: 'var(--bg-tertiary)', fontSize: 11, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<FolderOpen size={11} />
|
||||||
|
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
|
||||||
|
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
|
||||||
|
<button onClick={() => syncAlbum(link.id)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||||
|
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
</button>
|
||||||
|
{link.user_id === currentUser?.id && (
|
||||||
|
<button onClick={() => unlinkAlbum(link.id)} title={t('memories.unlinkAlbum')}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter & Sort bar */}
|
{/* Filter & Sort bar */}
|
||||||
@@ -470,12 +631,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
setLightboxOriginalSrc('')
|
||||||
|
getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc)
|
||||||
setLightboxInfoLoading(true)
|
setLightboxInfoLoading(true)
|
||||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
{/* Other user's avatar */}
|
{/* Other user's avatar */}
|
||||||
@@ -592,7 +755,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
</button>
|
</button>
|
||||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
<img
|
<img
|
||||||
src={originalUrl(lightboxId, lightboxUserId)}
|
src={lightboxOriginalSrc}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||||
@@ -77,9 +78,10 @@ interface ArtikelZeileProps {
|
|||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
|
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(item.name)
|
const [editName, setEditName] = useState(item.name)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
@@ -130,7 +132,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{editing ? (
|
{editing && canEdit ? (
|
||||||
<input
|
<input
|
||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
onClick={() => !item.checked && setEditing(true)}
|
onClick={() => canEdit && !item.checked && setEditing(true)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: item.checked ? 'default' : 'text',
|
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
@@ -159,7 +161,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
<input
|
<input
|
||||||
type="text" inputMode="numeric"
|
type="text" inputMode="numeric"
|
||||||
value={item.weight_grams ?? ''}
|
value={item.weight_grams ?? ''}
|
||||||
|
readOnly={!canEdit}
|
||||||
onChange={async e => {
|
onChange={async e => {
|
||||||
|
if (!canEdit) return
|
||||||
const raw = e.target.value.replace(/[^0-9]/g, '')
|
const raw = e.target.value.replace(/[^0-9]/g, '')
|
||||||
const v = raw === '' ? null : parseInt(raw)
|
const v = raw === '' ? null : parseInt(raw)
|
||||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
|
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
|
||||||
@@ -171,9 +175,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowBagPicker(p => !p)}
|
onClick={() => canEdit && setShowBagPicker(p => !p)}
|
||||||
style={{
|
style={{
|
||||||
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
||||||
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
|
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
|
||||||
}}
|
}}
|
||||||
@@ -247,6 +251,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
@@ -287,6 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
|
|||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
|
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
const [editKatName, setEditKatName] = useState(kategorie)
|
||||||
@@ -380,7 +387,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
|
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||||
|
|
||||||
{editingName ? (
|
{editingName && canEdit ? (
|
||||||
<input
|
<input
|
||||||
autoFocus value={editKatName}
|
autoFocus value={editKatName}
|
||||||
onChange={e => setEditKatName(e.target.value)}
|
onChange={e => setEditKatName(e.target.value)}
|
||||||
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||||
{assignees.map(a => (
|
{assignees.map(a => (
|
||||||
<div key={a.user_id} style={{ position: 'relative' }}
|
<div key={a.user_id} style={{ position: 'relative' }}
|
||||||
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||||
>
|
>
|
||||||
<div className="assignee-chip"
|
<div className="assignee-chip"
|
||||||
style={{
|
style={{
|
||||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
|
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||||
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
@@ -422,6 +429,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{canEdit && (
|
||||||
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||||
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||||
style={{
|
style={{
|
||||||
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -497,11 +506,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||||
onMouseLeave={() => setShowMenu(false)}>
|
onMouseLeave={() => setShowMenu(false)}>
|
||||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||||
|
{canEdit && <>
|
||||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
{offen && (
|
{offen && (
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
<div style={{ padding: '4px 4px 6px' }}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
|
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||||
))}
|
))}
|
||||||
{/* Inline add item */}
|
{/* Inline add item */}
|
||||||
{showAddItem ? (
|
{canEdit && (showAddItem ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||||
<input
|
<input
|
||||||
ref={addItemRef}
|
ref={addItemRef}
|
||||||
@@ -548,7 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Plus size={12} /> {t('packing.addItem')}
|
<Plus size={12} /> {t('packing.addItem')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -589,6 +600,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('packing_edit', trip)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -814,7 +828,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
{abgehakt > 0 && (
|
{canEdit && abgehakt > 0 && (
|
||||||
<button onClick={handleClearChecked} style={{
|
<button onClick={handleClearChecked} style={{
|
||||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -823,6 +837,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canEdit && (
|
||||||
<button onClick={() => setShowImportModal(true)} style={{
|
<button onClick={() => setShowImportModal(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||||
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
}}>
|
}}>
|
||||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
</button>
|
</button>
|
||||||
{availableTemplates.length > 0 && (
|
)}
|
||||||
|
{canEdit && availableTemplates.length > 0 && (
|
||||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
@@ -899,7 +915,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addingCategory ? (
|
{canEdit && (addingCategory ? (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -924,7 +940,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
<FolderPlus size={14} /> {t('packing.addCategory')}
|
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Filter-Tabs ── */}
|
{/* ── Filter-Tabs ── */}
|
||||||
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
bagTrackingEnabled={bagTrackingEnabled}
|
bagTrackingEnabled={bagTrackingEnabled}
|
||||||
bags={bags}
|
bags={bags}
|
||||||
onCreateBag={handleCreateBagByName}
|
onCreateBag={handleCreateBagByName}
|
||||||
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||||
</span>
|
</span>
|
||||||
|
{canEdit && (
|
||||||
<button onClick={() => handleDeleteBag(bag.id)}
|
<button onClick={() => handleDeleteBag(bag.id)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||||
<X size={11} />
|
<X size={11} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||||
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add bag */}
|
{/* Add bag */}
|
||||||
{showAddBag ? (
|
{canEdit && (showAddBag ? (
|
||||||
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
||||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||||
@@ -1054,16 +1073,16 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
|
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
|
||||||
<Plus size={11} /> {t('packing.addBag')}
|
<Plus size={11} /> {t('packing.addBag')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Bag Modal (mobile + click) ── */}
|
{/* ── Bag Modal (mobile + click) ── */}
|
||||||
{showBagModal && bagTrackingEnabled && (
|
{showBagModal && bagTrackingEnabled && (
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||||
onClick={() => setShowBagModal(false)}>
|
onClick={() => setShowBagModal(false)}>
|
||||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
|
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
||||||
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||||
</span>
|
</span>
|
||||||
|
{canEdit && (
|
||||||
<button onClick={() => handleDeleteBag(bag.id)}
|
<button onClick={() => handleDeleteBag(bag.id)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||||
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add bag */}
|
{/* Add bag */}
|
||||||
{showAddBag ? (
|
{canEdit && (showAddBag ? (
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||||
@@ -1142,7 +1163,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
<Plus size={14} /> {t('packing.addBag')}
|
<Plus size={14} /> {t('packing.addBag')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
|||||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
|
|||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canEditDays = can('day_edit', tripObj)
|
||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
@@ -111,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
check_out: hotelForm.check_out || null,
|
check_out: hotelForm.check_out || null,
|
||||||
confirmation: hotelForm.confirmation || null,
|
confirmation: hotelForm.confirmation || null,
|
||||||
})
|
})
|
||||||
setAccommodation(data.accommodation)
|
const newAcc = data.accommodation
|
||||||
setAccommodations(prev => [...prev, data.accommodation])
|
const updated = [...accommodations, newAcc]
|
||||||
|
setAccommodations(updated)
|
||||||
|
setAccommodation(newAcc)
|
||||||
|
setDayAccommodations(updated.filter(a =>
|
||||||
|
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||||
|
))
|
||||||
setShowHotelPicker(false)
|
setShowHotelPicker(false)
|
||||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
@@ -132,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
if (!accommodation) return
|
if (!accommodation) return
|
||||||
try {
|
try {
|
||||||
await accommodationsApi.delete(tripId, accommodation.id)
|
await accommodationsApi.delete(tripId, accommodation.id)
|
||||||
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||||
|
setAccommodations(updated)
|
||||||
|
setDayAccommodations(updated.filter(a =>
|
||||||
|
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||||
|
))
|
||||||
setAccommodation(null)
|
setAccommodation(null)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -328,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
{/* Details grid */}
|
{/* Details grid */}
|
||||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||||
@@ -385,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* Add another hotel */}
|
{/* Add another hotel */}
|
||||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
|
||||||
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Hotel size={10} /> {t('day.addAccommodation')}
|
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
|
||||||
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||||
</button>
|
</button> : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||||
@@ -549,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
// Reload
|
// Reload
|
||||||
accommodationsApi.list(tripId).then(d => {
|
accommodationsApi.list(tripId).then(d => {
|
||||||
setAccommodations(d.accommodations || [])
|
const all = d.accommodations || []
|
||||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
setAccommodations(all)
|
||||||
|
setDayAccommodations(all.filter(a =>
|
||||||
|
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
||||||
|
))
|
||||||
|
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||||
setAccommodation(acc || null)
|
setAccommodation(acc || null)
|
||||||
})
|
})
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||||
|
|
||||||
@@ -12,10 +12,13 @@ import { downloadTripPDF } from '../PDF/TripPDF'
|
|||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import WeatherWidget from '../Weather/WeatherWidget'
|
import WeatherWidget from '../Weather/WeatherWidget'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||||
@@ -76,9 +79,10 @@ interface DayPlanSidebarProps {
|
|||||||
reservations?: Reservation[]
|
reservations?: Reservation[]
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
onNavigateToFiles?: () => void
|
onNavigateToFiles?: () => void
|
||||||
|
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayPlanSidebar({
|
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||||
tripId,
|
tripId,
|
||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||||
@@ -88,12 +92,15 @@ export default function DayPlanSidebar({
|
|||||||
reservations = [],
|
reservations = [],
|
||||||
onAddReservation,
|
onAddReservation,
|
||||||
onNavigateToFiles,
|
onNavigateToFiles,
|
||||||
|
onExpandedDaysChange,
|
||||||
}: DayPlanSidebarProps) {
|
}: DayPlanSidebarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
const tripStore = useTripStore()
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
|
const can = useCanDo()
|
||||||
|
const canEditDays = can('day_edit', trip)
|
||||||
|
|
||||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||||
|
|
||||||
@@ -104,6 +111,7 @@ export default function DayPlanSidebar({
|
|||||||
} catch {}
|
} catch {}
|
||||||
return new Set(days.map(d => d.id))
|
return new Set(days.map(d => d.id))
|
||||||
})
|
})
|
||||||
|
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
|
||||||
const [editingDayId, setEditingDayId] = useState(null)
|
const [editingDayId, setEditingDayId] = useState(null)
|
||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
@@ -323,6 +331,16 @@ export default function DayPlanSidebar({
|
|||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const mergedItemsMap = useMemo(() => {
|
||||||
|
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
|
||||||
|
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
|
||||||
|
return map
|
||||||
|
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [days, assignments, dayNotes, reservations])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -410,7 +428,7 @@ export default function DayPlanSidebar({
|
|||||||
try {
|
try {
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
for (const n of noteUpdates) {
|
for (const n of noteUpdates) {
|
||||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||||
}
|
}
|
||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
for (const tu of transportUpdates) {
|
for (const tu of transportUpdates) {
|
||||||
@@ -503,7 +521,7 @@ export default function DayPlanSidebar({
|
|||||||
currentAssignments[key] = currentAssignments[key].map(a =>
|
currentAssignments[key] = currentAssignments[key].map(a =>
|
||||||
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
||||||
)
|
)
|
||||||
tripStore.setAssignments(currentAssignments)
|
tripActions.setAssignments(currentAssignments)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||||
@@ -632,14 +650,15 @@ export default function DayPlanSidebar({
|
|||||||
|
|
||||||
const handleDropOnDay = (e, dayId) => {
|
const handleDropOnDay = (e, dayId) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
setDragOverDayId(null)
|
setDragOverDayId(null)
|
||||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), dayId)
|
onAssignToDay?.(parseInt(placeId), dayId)
|
||||||
} else if (assignmentId && fromDayId !== dayId) {
|
} else if (assignmentId && fromDayId !== dayId) {
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
} else if (noteId && fromDayId !== dayId) {
|
} else if (noteId && fromDayId !== dayId) {
|
||||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
}
|
}
|
||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
setDropTargetKey(null)
|
setDropTargetKey(null)
|
||||||
@@ -668,10 +687,10 @@ export default function DayPlanSidebar({
|
|||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCost = days.reduce((s, d) => {
|
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||||
const da = assignments[String(d.id)] || []
|
const da = assignments[String(d.id)] || []
|
||||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||||
}, 0)
|
}, 0), [days, assignments])
|
||||||
|
|
||||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||||
@@ -718,7 +737,7 @@ export default function DayPlanSidebar({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error()
|
if (!res.ok) throw new Error()
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -755,12 +774,12 @@ export default function DayPlanSidebar({
|
|||||||
const formattedDate = formatDate(day.date, locale)
|
const formattedDate = formatDate(day.date, locale)
|
||||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||||
const isDragTarget = dragOverDayId === day.id
|
const isDragTarget = dragOverDayId === day.id
|
||||||
const merged = getMergedItems(day.id)
|
const merged = mergedItemsMap[day.id] || []
|
||||||
const dayNoteUi = noteUi[day.id]
|
const dayNoteUi = noteUi[day.id]
|
||||||
const placeItems = merged.filter(i => i.type === 'place')
|
const placeItems = merged.filter(i => i.type === 'place')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)', contentVisibility: 'auto', containIntrinsicSize: '0 64px' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||||
@@ -810,15 +829,15 @@ export default function DayPlanSidebar({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{canEditDays && <button
|
||||||
onClick={e => startEditTitle(day, e)}
|
onClick={e => startEditTitle(day, e)}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>
|
</button>}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||||
// Sort: check-out first, then ongoing stays, then check-in last
|
// Sort: check-out first, then ongoing stays, then check-in last
|
||||||
@@ -862,20 +881,20 @@ export default function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{canEditDays && <button
|
||||||
onClick={e => openAddNote(day.id, e)}
|
onClick={e => openAddNote(day.id, e)}
|
||||||
title={t('dayplan.addNote')}
|
title={t('dayplan.addNote')}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||||
>
|
>
|
||||||
<FileText size={13} strokeWidth={2} />
|
<FileText size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>}
|
||||||
<button
|
<button
|
||||||
onClick={e => toggleDay(day.id, e)}
|
onClick={e => toggleDay(day.id, e)}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
|
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -886,6 +905,7 @@ export default function DayPlanSidebar({
|
|||||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||||
@@ -894,11 +914,11 @@ export default function DayPlanSidebar({
|
|||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
} else if (assignmentId && fromDayId !== day.id) {
|
} else if (assignmentId && fromDayId !== day.id) {
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
} else if (assignmentId) {
|
} else if (assignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
||||||
}
|
}
|
||||||
@@ -912,11 +932,11 @@ export default function DayPlanSidebar({
|
|||||||
setDropTargetKey(null); window.__dragData = null; return
|
setDropTargetKey(null); window.__dragData = null; return
|
||||||
}
|
}
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
}
|
}
|
||||||
if (noteId && fromDayId !== day.id) {
|
if (noteId && fromDayId !== day.id) {
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
}
|
}
|
||||||
const m = getMergedItems(day.id)
|
const m = getMergedItems(day.id)
|
||||||
@@ -992,8 +1012,9 @@ export default function DayPlanSidebar({
|
|||||||
<React.Fragment key={`place-${assignment.id}`}>
|
<React.Fragment key={`place-${assignment.id}`}>
|
||||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={canEditDays}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
|
if (!canEditDays) { e.preventDefault(); return }
|
||||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
@@ -1010,7 +1031,7 @@ export default function DayPlanSidebar({
|
|||||||
setDropTargetKey(null); window.__dragData = null
|
setDropTargetKey(null); window.__dragData = null
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||||
@@ -1018,7 +1039,7 @@ export default function DayPlanSidebar({
|
|||||||
const tm = getMergedItems(day.id)
|
const tm = getMergedItems(day.id)
|
||||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||||
@@ -1027,12 +1048,12 @@ export default function DayPlanSidebar({
|
|||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={e => ctxMenu.open(e, [
|
||||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||||
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||||
])}
|
])}
|
||||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
@@ -1050,9 +1071,9 @@ export default function DayPlanSidebar({
|
|||||||
opacity: isDraggingThis ? 0.4 : 1,
|
opacity: isDraggingThis ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>}
|
||||||
<div
|
<div
|
||||||
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||||
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||||
@@ -1103,10 +1124,8 @@ export default function DayPlanSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(place.description || place.address || cat?.name) && (
|
{(place.description || place.address || cat?.name) && (
|
||||||
<div style={{ marginTop: 2 }}>
|
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||||
{place.description || place.address || cat?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1155,14 +1174,14 @@ export default function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
<ChevronUp size={12} strokeWidth={2} />
|
<ChevronUp size={12} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
<ChevronDown size={12} strokeWidth={2} />
|
<ChevronDown size={12} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
@@ -1199,11 +1218,11 @@ export default function DayPlanSidebar({
|
|||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
||||||
}
|
}
|
||||||
@@ -1261,8 +1280,8 @@ export default function DayPlanSidebar({
|
|||||||
<React.Fragment key={`note-${note.id}`}>
|
<React.Fragment key={`note-${note.id}`}>
|
||||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={canEditDays}
|
||||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
@@ -1272,7 +1291,7 @@ export default function DayPlanSidebar({
|
|||||||
const tm = getMergedItems(day.id)
|
const tm = getMergedItems(day.id)
|
||||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null)
|
setDraggingId(null); setDropTargetKey(null)
|
||||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||||
@@ -1280,17 +1299,17 @@ export default function DayPlanSidebar({
|
|||||||
const tm = getMergedItems(day.id)
|
const tm = getMergedItems(day.id)
|
||||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null)
|
setDraggingId(null); setDropTargetKey(null)
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
||||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||||
])}
|
]) : undefined}
|
||||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1304,9 +1323,9 @@ export default function DayPlanSidebar({
|
|||||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>}
|
||||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1315,17 +1334,17 @@ export default function DayPlanSidebar({
|
|||||||
{note.text}
|
{note.text}
|
||||||
</span>
|
</span>
|
||||||
{note.time && (
|
{note.time && (
|
||||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||||
</div>
|
</div>}
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
@@ -1345,11 +1364,11 @@ export default function DayPlanSidebar({
|
|||||||
}
|
}
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
}
|
}
|
||||||
if (noteId && fromDayId !== day.id) {
|
if (noteId && fromDayId !== day.id) {
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
}
|
}
|
||||||
const m = getMergedItems(day.id)
|
const m = getMergedItems(day.id)
|
||||||
@@ -1406,7 +1425,7 @@ export default function DayPlanSidebar({
|
|||||||
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
||||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
||||||
<div key={dayId} style={{
|
<div key={dayId} style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 1000,
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
}} onClick={() => cancelNote(Number(dayId))}>
|
}} onClick={() => cancelNote(Number(dayId))}>
|
||||||
@@ -1423,8 +1442,8 @@ export default function DayPlanSidebar({
|
|||||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
{NOTE_ICONS.map(({ id, Icon }) => (
|
||||||
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
||||||
title={id}
|
title={id}
|
||||||
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||||
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1446,7 +1465,7 @@ export default function DayPlanSidebar({
|
|||||||
placeholder={t('dayplan.noteSubtitle')}
|
placeholder={t('dayplan.noteSubtitle')}
|
||||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
|
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||||
@@ -1594,13 +1613,13 @@ export default function DayPlanSidebar({
|
|||||||
{res.notes && (
|
{res.notes && (
|
||||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dateien */}
|
{/* Dateien */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const resFiles = (tripStore.files || []).filter(f =>
|
const resFiles = (useTripStore.getState().files || []).filter(f =>
|
||||||
!f.deleted_at && (
|
!f.deleted_at && (
|
||||||
f.reservation_id === res.id ||
|
f.reservation_id === res.id ||
|
||||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||||
@@ -1661,4 +1680,6 @@ export default function DayPlanSidebar({
|
|||||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default DayPlanSidebar
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { hasMapsKey } = useAuthStore()
|
const { hasMapsKey } = useAuthStore()
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canUploadFiles = can('file_upload', tripObj)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
|
|||||||
|
|
||||||
// Paste support for files/images
|
// Paste support for files/images
|
||||||
const handlePaste = (e) => {
|
const handlePaste = (e) => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Attachments */}
|
{/* File Attachments */}
|
||||||
{true && (
|
{canUploadFiles && (
|
||||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -116,7 +119,7 @@ interface PlaceInspectorProps {
|
|||||||
onAssignToDay: (placeId: number, dayId: number) => void
|
onAssignToDay: (placeId: number, dayId: number) => void
|
||||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||||
files: TripFile[]
|
files: TripFile[]
|
||||||
onFileUpload: (fd: FormData) => Promise<void>
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||||
@@ -339,10 +342,8 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || place.notes || googleDetails?.summary) && (
|
{(place.description || place.notes || googleDetails?.summary) && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||||
{place.description || place.notes || googleDetails?.summary}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -391,7 +392,7 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
||||||
{(() => {
|
{(() => {
|
||||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
if (!meta || Object.keys(meta).length === 0) return null
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
@@ -461,6 +462,98 @@ export default function PlaceInspector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* GPX Track stats */}
|
||||||
|
{place.route_geometry && (() => {
|
||||||
|
try {
|
||||||
|
const pts: number[][] = JSON.parse(place.route_geometry)
|
||||||
|
if (!pts || pts.length < 2) return null
|
||||||
|
const hasEle = pts[0].length >= 3
|
||||||
|
|
||||||
|
// Haversine distance
|
||||||
|
const toRad = (d: number) => d * Math.PI / 180
|
||||||
|
let totalDist = 0
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
|
||||||
|
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
|
||||||
|
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
const distKm = totalDist / 1000
|
||||||
|
|
||||||
|
// Elevation stats
|
||||||
|
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
|
||||||
|
if (hasEle) {
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const e = pts[i][2]
|
||||||
|
if (e < minEle) minEle = e
|
||||||
|
if (e > maxEle) maxEle = e
|
||||||
|
if (i > 0) {
|
||||||
|
const diff = e - pts[i - 1][2]
|
||||||
|
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation profile SVG
|
||||||
|
const chartW = 280, chartH = 60
|
||||||
|
const elevations = hasEle ? pts.map(p => p[2]) : []
|
||||||
|
let pathD = ''
|
||||||
|
if (elevations.length > 1) {
|
||||||
|
const step = Math.max(1, Math.floor(elevations.length / chartW))
|
||||||
|
const sampled = elevations.filter((_, i) => i % step === 0)
|
||||||
|
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
|
||||||
|
const range = eMax - eMin || 1
|
||||||
|
pathD = sampled.map((e, i) => {
|
||||||
|
const x = (i / (sampled.length - 1)) * chartW
|
||||||
|
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<TrendingUp size={13} color="#9ca3af" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||||
|
<MapPin size={12} color="#3b82f6" />
|
||||||
|
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
||||||
|
</div>
|
||||||
|
{hasEle && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||||
|
<Mountain size={12} color="#22c55e" />
|
||||||
|
{Math.round(maxEle)} m
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||||
|
<Mountain size={12} color="#ef4444" />
|
||||||
|
{Math.round(minEle)} m
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pathD && (
|
||||||
|
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
|
||||||
|
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch { return null }
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Files section */}
|
{/* Files section */}
|
||||||
{(placeFiles.length > 0 || onFileUpload) && (
|
{(placeFiles.length > 0 || onFileUpload) && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
@@ -489,11 +582,11 @@ export default function PlaceInspector({
|
|||||||
{filesExpanded && placeFiles.length > 0 && (
|
{filesExpanded && placeFiles.length > 0 && (
|
||||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{placeFiles.map(f => (
|
{placeFiles.map(f => (
|
||||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
|
|||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
import { placesApi } from '../../api/client'
|
import { placesApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
@@ -30,7 +31,7 @@ interface PlacesSidebarProps {
|
|||||||
onCategoryFilterChange?: (categoryId: string) => void
|
onCategoryFilterChange?: (categoryId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlacesSidebar({
|
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||||
}: PlacesSidebarProps) {
|
}: PlacesSidebarProps) {
|
||||||
@@ -38,7 +39,10 @@ export default function PlacesSidebar({
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||||
const tripStore = useTripStore()
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
|
const can = useCanDo()
|
||||||
|
const canEditPlaces = can('place_edit', trip)
|
||||||
|
|
||||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -46,12 +50,33 @@ export default function PlacesSidebar({
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
try {
|
try {
|
||||||
const result = await placesApi.importGpx(tripId, file)
|
const result = await placesApi.importGpx(tripId, file)
|
||||||
await tripStore.loadTrip(tripId)
|
await loadTrip(tripId)
|
||||||
toast.success(t('places.gpxImported', { count: result.count }))
|
toast.success(t('places.gpxImported', { count: result.count }))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [googleListOpen, setGoogleListOpen] = useState(false)
|
||||||
|
const [googleListUrl, setGoogleListUrl] = useState('')
|
||||||
|
const [googleListLoading, setGoogleListLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleGoogleListImport = async () => {
|
||||||
|
if (!googleListUrl.trim()) return
|
||||||
|
setGoogleListLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
||||||
|
await loadTrip(tripId)
|
||||||
|
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
||||||
|
setGoogleListOpen(false)
|
||||||
|
setGoogleListUrl('')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
||||||
|
} finally {
|
||||||
|
setGoogleListLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||||
@@ -67,11 +92,12 @@ export default function PlacesSidebar({
|
|||||||
}
|
}
|
||||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||||
|
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||||
const plannedIds = new Set(
|
const plannedIds = useMemo(() => new Set(
|
||||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||||
)
|
), [assignments])
|
||||||
|
|
||||||
const filtered = useMemo(() => places.filter(p => {
|
const filtered = useMemo(() => places.filter(p => {
|
||||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||||
@@ -79,7 +105,7 @@ export default function PlacesSidebar({
|
|||||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}), [places, filter, categoryFilters, search, plannedIds.size])
|
}), [places, filter, categoryFilters, search, plannedIds])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||||
@@ -88,7 +114,7 @@ export default function PlacesSidebar({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Kopfbereich */}
|
{/* Kopfbereich */}
|
||||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
<button
|
{canEditPlaces && <button
|
||||||
onClick={onAddPlace}
|
onClick={onAddPlace}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
@@ -98,20 +124,36 @@ export default function PlacesSidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||||
</button>
|
</button>}
|
||||||
|
{canEditPlaces && <>
|
||||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||||
<button
|
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||||
onClick={() => gpxInputRef.current?.click()}
|
<button
|
||||||
style={{
|
onClick={() => gpxInputRef.current?.click()}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
style={{
|
||||||
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
border: '1px dashed var(--border-primary)', background: 'none',
|
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
border: '1px dashed var(--border-primary)', background: 'none',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||||
}}
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
>
|
}}
|
||||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
>
|
||||||
</button>
|
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGoogleListOpen(true)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||||
|
border: '1px dashed var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||||
@@ -223,9 +265,9 @@ export default function PlacesSidebar({
|
|||||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||||
{t('places.addPlace')}
|
{t('places.addPlace')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(place => {
|
filtered.map(place => {
|
||||||
@@ -245,19 +287,19 @@ export default function PlacesSidebar({
|
|||||||
window.__dragData = { placeId: String(place.id) }
|
window.__dragData = { placeId: String(place.id) }
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isMobile && days?.length > 0) {
|
if (isMobile) {
|
||||||
setDayPickerPlace(place)
|
setDayPickerPlace(place)
|
||||||
} else {
|
} else {
|
||||||
onPlaceClick(isSelected ? null : place.id)
|
onPlaceClick(isSelected ? null : place.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={e => ctxMenu.open(e, [
|
||||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||||
])}
|
])}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
@@ -312,49 +354,133 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
{dayPickerPlace && ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
onClick={() => setDayPickerPlace(null)}
|
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
{dayPickerPlace.address && <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{dayPickerPlace.address}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
|
||||||
{days.map((day, i) => {
|
{/* View details */}
|
||||||
return (
|
<button
|
||||||
|
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<Eye size={18} color="var(--text-muted)" /> {t('places.viewDetails')}
|
||||||
|
</button>
|
||||||
|
{/* Edit */}
|
||||||
|
{canEditPlaces && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<Pencil size={18} color="var(--text-muted)" /> {t('common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Assign to day */}
|
||||||
|
{days?.length > 0 && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
key={day.id}
|
onClick={() => setMobileShowDays(v => !v)}
|
||||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
|
||||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
|
||||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
|
||||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
|
||||||
}}>{i + 1}</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
|
||||||
</div>
|
|
||||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
|
||||||
</div>
|
|
||||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
{mobileShowDays && (
|
||||||
})}
|
<div style={{ paddingLeft: 20 }}>
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<button
|
||||||
|
key={day.id}
|
||||||
|
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||||
|
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||||
|
</div>
|
||||||
|
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Delete */}
|
||||||
|
{canEditPlaces && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: '#ef4444' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} /> {t('common.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
{googleListOpen && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||||
|
{t('places.importGoogleList')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||||
|
{t('places.googleListHint')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={googleListUrl}
|
||||||
|
onChange={e => setGoogleListUrl(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
||||||
|
placeholder="https://maps.app.goo.gl/..."
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
|
||||||
|
fontSize: 13, color: 'var(--text-primary)', outline: 'none',
|
||||||
|
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||||
|
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGoogleListImport}
|
||||||
|
disabled={!googleListUrl.trim() || googleListLoading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||||
|
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||||
|
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||||
|
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{googleListLoading ? t('common.loading') : t('common.import')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -363,4 +489,6 @@ export default function PlacesSidebar({
|
|||||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default PlacesSidebar
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ interface ReservationModalProps {
|
|||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
selectedDayId: number | null
|
selectedDayId: number | null
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onFileUpload: (fd: FormData) => Promise<void>
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
onFileDelete: (fileId: number) => Promise<void>
|
onFileDelete: (fileId: number) => Promise<void>
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
}
|
}
|
||||||
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
))}
|
))}
|
||||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Paperclip size={11} />
|
<Paperclip size={11} />
|
||||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||||
</button>
|
</button>}
|
||||||
{/* Link existing file picker */}
|
{/* Link existing file picker */}
|
||||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -56,9 +57,10 @@ interface ReservationCardProps {
|
|||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||||
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||||
const { toggleReservationStatus } = useTripStore()
|
const { toggleReservationStatus } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
{canEdit ? (
|
||||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||||
</button>
|
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||||
|
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
{canEdit && (
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
<Pencil size={11} />
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
</button>
|
<Pencil size={11} />
|
||||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
</button>
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
)}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
{canEdit && (
|
||||||
<Trash2 size={11} />
|
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||||
</button>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
@@ -131,7 +143,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -330,6 +342,9 @@ interface ReservationsPanelProps {
|
|||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('reservation_edit', trip)
|
||||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||||
|
|
||||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||||
@@ -348,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onAdd} style={{
|
{canEdit && (
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
<button onClick={onAdd} style={{
|
||||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
}}>
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
}}>
|
||||||
</button>
|
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
{allPending.length > 0 && (
|
{allPending.length > 0 && (
|
||||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{allConfirmed.length > 0 && (
|
{allConfirmed.length > 0 && (
|
||||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi } from '../../api/client'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
@@ -23,13 +24,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const currentUser = useAuthStore(s => s.user)
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||||
|
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
|
||||||
|
const can = useCanDo()
|
||||||
|
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||||
|
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
|
reminder_days: 0 as number,
|
||||||
})
|
})
|
||||||
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
@@ -41,25 +49,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trip) {
|
if (trip) {
|
||||||
|
const rd = trip.reminder_days ?? 3
|
||||||
setFormData({
|
setFormData({
|
||||||
title: trip.title || '',
|
title: trip.title || '',
|
||||||
description: trip.description || '',
|
description: trip.description || '',
|
||||||
start_date: trip.start_date || '',
|
start_date: trip.start_date || '',
|
||||||
end_date: trip.end_date || '',
|
end_date: trip.end_date || '',
|
||||||
|
reminder_days: rd,
|
||||||
})
|
})
|
||||||
|
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||||
setCoverPreview(trip.cover_image || null)
|
setCoverPreview(trip.cover_image || null)
|
||||||
} else {
|
} else {
|
||||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
|
||||||
|
setCustomReminder(false)
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
}
|
}
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
setSelectedMembers([])
|
setSelectedMembers([])
|
||||||
setError('')
|
setError('')
|
||||||
|
if (isOpen) {
|
||||||
|
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||||
|
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [trip, isOpen])
|
}, [trip, isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trip && isOpen) {
|
||||||
|
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||||
|
}
|
||||||
|
}, [tripRemindersEnabled])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -74,6 +97,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
description: formData.description.trim() || null,
|
description: formData.description.trim() || null,
|
||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
|
reminder_days: formData.reminder_days,
|
||||||
})
|
})
|
||||||
// Add selected members for newly created trips
|
// Add selected members for newly created trips
|
||||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||||
@@ -154,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
|
|
||||||
// Paste support for cover image
|
// Paste support for cover image
|
||||||
const handlePaste = (e) => {
|
const handlePaste = (e) => {
|
||||||
|
if (!canUploadCover) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -211,8 +236,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cover image — available for both create and edit */}
|
{/* Cover image — gated by trip_cover_upload permission */}
|
||||||
<div>
|
{canUploadCover && <div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||||
{coverPreview ? (
|
{coverPreview ? (
|
||||||
@@ -240,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||||
className={`${inputCls} resize-none`} />
|
className={`${inputCls} resize-none`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,6 +297,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder — only visible to owner (or when creating) */}
|
||||||
|
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||||
|
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
|
||||||
|
</label>
|
||||||
|
{!tripRemindersEnabled ? (
|
||||||
|
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||||
|
{t('trips.reminderDisabledHint')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 0, label: t('trips.reminderNone') },
|
||||||
|
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
|
||||||
|
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
|
||||||
|
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
|
!customReminder && formData.reminder_days === opt.value
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||||
|
}`}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
|
customReminder
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||||
|
}`}>
|
||||||
|
{t('trips.reminderCustom')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customReminder && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input type="number" min={1} max={30}
|
||||||
|
value={formData.reminder_days}
|
||||||
|
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||||
|
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members — only for new trips */}
|
{/* Members — only for new trips */}
|
||||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -312,11 +390,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!formData.start_date && !formData.end_date && (
|
|
||||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
|
||||||
{t('dashboard.noDateHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
|||||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||||
const [shareToken, setShareToken] = useState<string | null>(null)
|
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canManageMembers = can('member_manage', trip)
|
||||||
|
const canManageShare = can('share_manage', trip)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && tripId) {
|
if (isOpen && tripId) {
|
||||||
@@ -247,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
{/* Left column: Members */}
|
{/* Left column: Members */}
|
||||||
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add member dropdown */}
|
{/* Add member dropdown */}
|
||||||
<div>
|
{canManageMembers && <div>
|
||||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
{t('members.inviteUser')}
|
{t('members.inviteUser')}
|
||||||
</label>
|
</label>
|
||||||
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{availableUsers.length === 0 && allUsers.length > 0 && (
|
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Members list */}
|
{/* Members list */}
|
||||||
<div>
|
<div>
|
||||||
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{allMembers.map(member => {
|
{allMembers.map(member => {
|
||||||
const isSelf = member.id === user?.id
|
const isSelf = member.id === user?.id
|
||||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||||
return (
|
return (
|
||||||
<div key={member.id} style={{
|
<div key={member.id} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: Share Link */}
|
{/* Right column: Share Link */}
|
||||||
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||||
<ShareLinkSection tripId={tripId} t={t} />
|
<ShareLinkSection tripId={tripId} t={t} />
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ interface CustomDatePickerProps {
|
|||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
|
compact?: boolean
|
||||||
|
borderless?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
|
||||||
const { locale, t } = useTranslation()
|
const { locale, t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||||
|
|
||||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||||
|
|
||||||
const selectDay = (day: number) => {
|
const selectDay = (day: number) => {
|
||||||
const y = String(viewYear)
|
const y = String(viewYear)
|
||||||
@@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
|
||||||
padding: '8px 14px', borderRadius: 10,
|
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
|
||||||
border: '1px solid var(--border-primary)',
|
border: borderless ? 'none' : '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface CustomSelectProps {
|
|||||||
searchable?: boolean
|
searchable?: boolean
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
size?: 'sm' | 'md'
|
size?: 'sm' | 'md'
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomSelect({
|
export default function CustomSelect({
|
||||||
@@ -29,6 +30,7 @@ export default function CustomSelect({
|
|||||||
searchable = false,
|
searchable = false,
|
||||||
style = {},
|
style = {},
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
}: CustomSelectProps) {
|
}: CustomSelectProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -83,17 +85,19 @@ export default function CustomSelect({
|
|||||||
{/* Trigger */}
|
{/* Trigger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
|
||||||
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
|||||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
onChange(raw)
|
onChange(raw)
|
||||||
|
if (is12h) return // let handleBlur parse 12h formats
|
||||||
const clean = raw.replace(/[^0-9:]/g, '')
|
const clean = raw.replace(/[^0-9:]/g, '')
|
||||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||||
@@ -80,7 +81,23 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
|||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const clean = value.replace(/[^0-9:]/g, '')
|
const raw = value.trim()
|
||||||
|
|
||||||
|
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
|
||||||
|
if (is12h) {
|
||||||
|
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
|
||||||
|
if (match12) {
|
||||||
|
let h = parseInt(match12[1])
|
||||||
|
const m = match12[2] ? parseInt(match12[2]) : 0
|
||||||
|
const isPm = match12[3].toLowerCase() === 'pm'
|
||||||
|
if (h === 12) h = isPm ? 12 : 0
|
||||||
|
else if (isPm) h += 12
|
||||||
|
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = raw.replace(/[^0-9:]/g, '')
|
||||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||||
const [hh, mm] = clean.split(':')
|
const [hh, mm] = clean.split(':')
|
||||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { mapsApi } from '../../api/client'
|
|
||||||
import { getCategoryIcon } from './categoryIcons'
|
import { getCategoryIcon } from './categoryIcons'
|
||||||
|
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
|
|||||||
category?: Category | null
|
category?: Category | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const photoCache = new Map<string, string | null>()
|
|
||||||
const photoInFlight = new Set<string>()
|
|
||||||
// Event-based notification instead of polling intervals
|
|
||||||
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
|
|
||||||
|
|
||||||
function notifyListeners(key: string, url: string | null) {
|
|
||||||
const listeners = photoListeners.get(key)
|
|
||||||
if (listeners) {
|
|
||||||
listeners.forEach(fn => fn(url))
|
|
||||||
photoListeners.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Observe visibility — fetch photo only when avatar enters viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (place.image_url) { setVisible(true); return }
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
// Check if already cached — show immediately without waiting for intersection
|
||||||
|
const photoId = place.google_place_id || place.osm_id
|
||||||
|
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||||
|
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
|
||||||
|
io.observe(el)
|
||||||
|
return () => io.disconnect()
|
||||||
|
}, [place.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||||
const photoId = place.google_place_id || place.osm_id
|
const photoId = place.google_place_id || place.osm_id
|
||||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||||
|
|
||||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||||
if (photoCache.has(cacheKey)) {
|
|
||||||
const cached = photoCache.get(cacheKey)
|
const cached = getCached(cacheKey)
|
||||||
if (cached) setPhotoSrc(cached)
|
if (cached) {
|
||||||
|
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
|
||||||
|
if (!cached.thumbDataUrl && cached.photoUrl) {
|
||||||
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (photoInFlight.has(cacheKey)) {
|
if (isLoading(cacheKey)) {
|
||||||
// Subscribe to notification instead of polling
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
|
||||||
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
|
||||||
photoListeners.get(cacheKey)!.add(handler)
|
|
||||||
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
photoInFlight.add(cacheKey)
|
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
|
||||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
.then((data: { photoUrl?: string }) => {
|
)
|
||||||
const url = data.photoUrl || null
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
photoCache.set(cacheKey, url)
|
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||||
if (url) setPhotoSrc(url)
|
|
||||||
notifyListeners(cacheKey, url)
|
|
||||||
photoInFlight.delete(cacheKey)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
photoCache.set(cacheKey, null)
|
|
||||||
notifyListeners(cacheKey, null)
|
|
||||||
photoInFlight.delete(cacheKey)
|
|
||||||
})
|
|
||||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
|
||||||
|
|
||||||
const bgColor = category?.color || '#6366f1'
|
const bgColor = category?.color || '#6366f1'
|
||||||
const IconComp = getCategoryIcon(category?.icon)
|
const IconComp = getCategoryIcon(category?.icon)
|
||||||
@@ -81,11 +76,11 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
|
|
||||||
if (photoSrc) {
|
if (photoSrc) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div ref={ref} style={containerStyle}>
|
||||||
<img
|
<img
|
||||||
src={photoSrc}
|
src={photoSrc}
|
||||||
alt={place.name}
|
alt={place.name}
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => setPhotoSrc(null)}
|
||||||
/>
|
/>
|
||||||
@@ -94,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div ref={ref} style={containerStyle}>
|
||||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
const currentAssignments = tripStore.assignments || {}
|
||||||
|
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||||
@@ -33,12 +34,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||||
else if (!(err instanceof Error)) setRouteSegments([])
|
else if (!(err instanceof Error)) setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [tripStore, routeCalcEnabled])
|
}, [routeCalcEnabled])
|
||||||
|
|
||||||
|
// Only recalculate when assignments for the SELECTED day change
|
||||||
|
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
}, [selectedDayId, tripStore.assignments])
|
}, [selectedDayId, selectedDayAssignments])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'تعديل',
|
'common.edit': 'تعديل',
|
||||||
'common.add': 'إضافة',
|
'common.add': 'إضافة',
|
||||||
'common.loading': 'جارٍ التحميل...',
|
'common.loading': 'جارٍ التحميل...',
|
||||||
|
'common.import': 'استيراد',
|
||||||
'common.error': 'خطأ',
|
'common.error': 'خطأ',
|
||||||
'common.back': 'رجوع',
|
'common.back': 'رجوع',
|
||||||
'common.all': 'الكل',
|
'common.all': 'الكل',
|
||||||
@@ -29,6 +30,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'البريد الإلكتروني',
|
'common.email': 'البريد الإلكتروني',
|
||||||
'common.password': 'كلمة المرور',
|
'common.password': 'كلمة المرور',
|
||||||
'common.saving': 'جارٍ الحفظ...',
|
'common.saving': 'جارٍ الحفظ...',
|
||||||
|
'common.saved': 'تم الحفظ',
|
||||||
|
'trips.reminder': 'تذكير',
|
||||||
|
'trips.reminderNone': 'بدون',
|
||||||
|
'trips.reminderDay': 'يوم',
|
||||||
|
'trips.reminderDays': 'أيام',
|
||||||
|
'trips.reminderCustom': 'مخصص',
|
||||||
|
'trips.reminderDaysBefore': 'أيام قبل المغادرة',
|
||||||
|
'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
|
||||||
'common.update': 'تحديث',
|
'common.update': 'تحديث',
|
||||||
'common.change': 'تغيير',
|
'common.change': 'تغيير',
|
||||||
'common.uploading': 'جارٍ الرفع...',
|
'common.uploading': 'جارٍ الرفع...',
|
||||||
@@ -154,9 +163,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
|
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
|
||||||
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
|
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
|
||||||
'settings.notifyWebhook': 'إشعارات Webhook',
|
'settings.notifyWebhook': 'إشعارات Webhook',
|
||||||
|
'settings.notificationsDisabled': 'الإشعارات غير مكوّنة. اطلب من المسؤول تفعيل إشعارات البريد الإلكتروني أو Webhook.',
|
||||||
|
'settings.notificationsActive': 'القناة النشطة',
|
||||||
|
'settings.notificationsManagedByAdmin': 'يتم تكوين أحداث الإشعارات بواسطة المسؤول.',
|
||||||
|
'admin.notifications.title': 'الإشعارات',
|
||||||
|
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
|
||||||
|
'admin.notifications.none': 'معطّل',
|
||||||
|
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'أحداث الإشعارات',
|
||||||
|
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
|
||||||
|
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
|
||||||
|
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
|
||||||
|
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
|
||||||
|
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح',
|
||||||
|
'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي',
|
||||||
'admin.smtp.title': 'البريد والإشعارات',
|
'admin.smtp.title': 'البريد والإشعارات',
|
||||||
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
|
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
|
||||||
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
||||||
|
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
|
||||||
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
||||||
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
||||||
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
||||||
@@ -190,6 +216,31 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'share.permCollab': 'الدردشة',
|
'share.permCollab': 'الدردشة',
|
||||||
'settings.on': 'تشغيل',
|
'settings.on': 'تشغيل',
|
||||||
'settings.off': 'إيقاف',
|
'settings.off': 'إيقاف',
|
||||||
|
'settings.mcp.title': 'إعداد MCP',
|
||||||
|
'settings.mcp.endpoint': 'نقطة نهاية MCP',
|
||||||
|
'settings.mcp.clientConfig': 'إعداد العميل',
|
||||||
|
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
|
||||||
|
'settings.mcp.copy': 'نسخ',
|
||||||
|
'settings.mcp.copied': 'تم النسخ!',
|
||||||
|
'settings.mcp.apiTokens': 'رموز API',
|
||||||
|
'settings.mcp.createToken': 'إنشاء رمز جديد',
|
||||||
|
'settings.mcp.noTokens': 'لا توجد رموز بعد. أنشئ رمزاً للاتصال بعملاء MCP.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'أُنشئ',
|
||||||
|
'settings.mcp.tokenUsedAt': 'استُخدم',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'حذف الرمز',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'سيتوقف هذا الرمز عن العمل فوراً. أي عميل MCP يستخدمه سيفقد الوصول.',
|
||||||
|
'settings.mcp.modal.createTitle': 'إنشاء رمز API',
|
||||||
|
'settings.mcp.modal.tokenName': 'اسم الرمز',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'مثال: Claude Desktop، حاسوب العمل',
|
||||||
|
'settings.mcp.modal.creating': 'جارٍ الإنشاء…',
|
||||||
|
'settings.mcp.modal.create': 'إنشاء الرمز',
|
||||||
|
'settings.mcp.modal.createdTitle': 'تم إنشاء الرمز',
|
||||||
|
'settings.mcp.modal.createdWarning': 'سيُعرض هذا الرمز مرة واحدة فقط. انسخه واحفظه الآن — لا يمكن استرداده.',
|
||||||
|
'settings.mcp.modal.done': 'تم',
|
||||||
|
'settings.mcp.toast.created': 'تم إنشاء الرمز',
|
||||||
|
'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
|
||||||
|
'settings.mcp.toast.deleted': 'تم حذف الرمز',
|
||||||
|
'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
|
||||||
'settings.account': 'الحساب',
|
'settings.account': 'الحساب',
|
||||||
'settings.username': 'اسم المستخدم',
|
'settings.username': 'اسم المستخدم',
|
||||||
'settings.email': 'البريد الإلكتروني',
|
'settings.email': 'البريد الإلكتروني',
|
||||||
@@ -197,6 +248,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.roleAdmin': 'مسؤول',
|
'settings.roleAdmin': 'مسؤول',
|
||||||
'settings.oidcLinked': 'مرتبط مع',
|
'settings.oidcLinked': 'مرتبط مع',
|
||||||
'settings.changePassword': 'تغيير كلمة المرور',
|
'settings.changePassword': 'تغيير كلمة المرور',
|
||||||
|
'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.',
|
||||||
'settings.currentPassword': 'كلمة المرور الحالية',
|
'settings.currentPassword': 'كلمة المرور الحالية',
|
||||||
'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة',
|
'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة',
|
||||||
'settings.newPassword': 'كلمة المرور الجديدة',
|
'settings.newPassword': 'كلمة المرور الجديدة',
|
||||||
@@ -205,7 +257,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
|
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
|
||||||
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
|
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
|
||||||
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||||
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم',
|
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
|
||||||
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
|
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
|
||||||
'settings.deleteAccount': 'حذف الحساب',
|
'settings.deleteAccount': 'حذف الحساب',
|
||||||
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
|
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
|
||||||
@@ -226,6 +278,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'فشل الرفع',
|
'settings.avatarError': 'فشل الرفع',
|
||||||
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
|
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
|
||||||
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
|
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.',
|
||||||
|
'settings.mfa.backupTitle': 'رموز النسخ الاحتياطي',
|
||||||
|
'settings.mfa.backupDescription': 'استخدم هذه الرموز لمرة واحدة إذا فقدت الوصول إلى تطبيق المصادقة.',
|
||||||
|
'settings.mfa.backupWarning': 'احفظ هذه الرموز الآن. كل رمز يمكن استخدامه مرة واحدة فقط.',
|
||||||
|
'settings.mfa.backupCopy': 'نسخ الرموز',
|
||||||
|
'settings.mfa.backupDownload': 'تنزيل TXT',
|
||||||
|
'settings.mfa.backupPrint': 'طباعة / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'تم نسخ رموز النسخ الاحتياطي',
|
||||||
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
|
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
|
||||||
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
|
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
|
||||||
'settings.mfa.setup': 'إعداد المصادقة',
|
'settings.mfa.setup': 'إعداد المصادقة',
|
||||||
@@ -268,6 +328,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'دخول',
|
'login.signIn': 'دخول',
|
||||||
'login.createAdmin': 'إنشاء حساب مسؤول',
|
'login.createAdmin': 'إنشاء حساب مسؤول',
|
||||||
'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.',
|
'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.',
|
||||||
|
'login.setNewPassword': 'تعيين كلمة مرور جديدة',
|
||||||
|
'login.setNewPasswordHint': 'يجب عليك تغيير كلمة المرور قبل المتابعة.',
|
||||||
'login.createAccount': 'إنشاء حساب',
|
'login.createAccount': 'إنشاء حساب',
|
||||||
'login.createAccountHint': 'سجّل حسابًا جديدًا.',
|
'login.createAccountHint': 'سجّل حسابًا جديدًا.',
|
||||||
'login.creating': 'جارٍ الإنشاء…',
|
'login.creating': 'جارٍ الإنشاء…',
|
||||||
@@ -294,7 +356,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||||
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل',
|
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
|
||||||
'register.failed': 'فشل التسجيل',
|
'register.failed': 'فشل التسجيل',
|
||||||
'register.getStarted': 'ابدأ الآن',
|
'register.getStarted': 'ابدأ الآن',
|
||||||
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
|
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
|
||||||
@@ -325,6 +387,20 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.config': 'الإعدادات',
|
'admin.tabs.config': 'الإعدادات',
|
||||||
'admin.tabs.templates': 'قوالب التعبئة',
|
'admin.tabs.templates': 'قوالب التعبئة',
|
||||||
'admin.tabs.addons': 'الإضافات',
|
'admin.tabs.addons': 'الإضافات',
|
||||||
|
'admin.tabs.mcpTokens': 'رموز MCP',
|
||||||
|
'admin.mcpTokens.title': 'رموز MCP',
|
||||||
|
'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين',
|
||||||
|
'admin.mcpTokens.owner': 'المالك',
|
||||||
|
'admin.mcpTokens.tokenName': 'اسم الرمز',
|
||||||
|
'admin.mcpTokens.created': 'تاريخ الإنشاء',
|
||||||
|
'admin.mcpTokens.lastUsed': 'آخر استخدام',
|
||||||
|
'admin.mcpTokens.never': 'أبداً',
|
||||||
|
'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'حذف الرمز',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
|
||||||
|
'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
|
||||||
|
'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
'admin.stats.users': 'المستخدمون',
|
'admin.stats.users': 'المستخدمون',
|
||||||
'admin.stats.trips': 'الرحلات',
|
'admin.stats.trips': 'الرحلات',
|
||||||
@@ -374,6 +450,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
||||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||||
|
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||||
|
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||||
'admin.apiKeys': 'مفاتيح API',
|
'admin.apiKeys': 'مفاتيح API',
|
||||||
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||||
'admin.mapsKey': 'مفتاح Google Maps API',
|
'admin.mapsKey': 'مفتاح Google Maps API',
|
||||||
@@ -425,8 +503,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// Addons
|
// Addons
|
||||||
'admin.addons.title': 'الإضافات',
|
'admin.addons.title': 'الإضافات',
|
||||||
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
||||||
'admin.addons.catalog.memories.name': 'ذكريات',
|
'admin.addons.catalog.memories.name': 'صور (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة',
|
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
|
||||||
'admin.addons.catalog.packing.name': 'التعبئة',
|
'admin.addons.catalog.packing.name': 'التعبئة',
|
||||||
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
||||||
'admin.addons.catalog.budget.name': 'الميزانية',
|
'admin.addons.catalog.budget.name': 'الميزانية',
|
||||||
@@ -445,8 +525,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.disabled': 'معطّل',
|
'admin.addons.disabled': 'معطّل',
|
||||||
'admin.addons.type.trip': 'رحلة',
|
'admin.addons.type.trip': 'رحلة',
|
||||||
'admin.addons.type.global': 'عام',
|
'admin.addons.type.global': 'عام',
|
||||||
|
'admin.addons.type.integration': 'تكامل',
|
||||||
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
|
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
|
||||||
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
|
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
|
||||||
|
'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
|
||||||
'admin.addons.toast.updated': 'تم تحديث الإضافة',
|
'admin.addons.toast.updated': 'تم تحديث الإضافة',
|
||||||
'admin.addons.toast.error': 'فشل تحديث الإضافة',
|
'admin.addons.toast.error': 'فشل تحديث الإضافة',
|
||||||
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
|
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
|
||||||
@@ -605,6 +687,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||||
'atlas.addPoi': 'إضافة مكان',
|
'atlas.addPoi': 'إضافة مكان',
|
||||||
|
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||||
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
|
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
|
||||||
'atlas.month': 'الشهر',
|
'atlas.month': 'الشهر',
|
||||||
'atlas.year': 'السنة',
|
'atlas.year': 'السنة',
|
||||||
@@ -613,7 +696,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'الإحصائيات',
|
'atlas.statsTab': 'الإحصائيات',
|
||||||
'atlas.bucketTab': 'قائمة الأمنيات',
|
'atlas.bucketTab': 'قائمة الأمنيات',
|
||||||
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
|
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
|
||||||
'atlas.bucketNamePlaceholder': 'مكان أو وجهة...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
|
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
|
||||||
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
|
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
|
||||||
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
|
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
|
||||||
@@ -626,7 +708,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.nextTrip': 'الرحلة القادمة',
|
'atlas.nextTrip': 'الرحلة القادمة',
|
||||||
'atlas.daysLeft': 'يوم متبقٍ',
|
'atlas.daysLeft': 'يوم متبقٍ',
|
||||||
'atlas.streak': 'سلسلة',
|
'atlas.streak': 'سلسلة',
|
||||||
'atlas.year': 'سنة',
|
|
||||||
'atlas.years': 'سنوات',
|
'atlas.years': 'سنوات',
|
||||||
'atlas.yearInRow': 'سنة متتالية',
|
'atlas.yearInRow': 'سنة متتالية',
|
||||||
'atlas.yearsInRow': 'سنوات متتالية',
|
'atlas.yearsInRow': 'سنوات متتالية',
|
||||||
@@ -656,6 +737,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.tabs.budget': 'الميزانية',
|
'trip.tabs.budget': 'الميزانية',
|
||||||
'trip.tabs.files': 'الملفات',
|
'trip.tabs.files': 'الملفات',
|
||||||
'trip.loading': 'جارٍ تحميل الرحلة...',
|
'trip.loading': 'جارٍ تحميل الرحلة...',
|
||||||
|
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
|
||||||
'trip.mobilePlan': 'الخطة',
|
'trip.mobilePlan': 'الخطة',
|
||||||
'trip.mobilePlaces': 'الأماكن',
|
'trip.mobilePlaces': 'الأماكن',
|
||||||
'trip.toast.placeUpdated': 'تم تحديث المكان',
|
'trip.toast.placeUpdated': 'تم تحديث المكان',
|
||||||
@@ -702,9 +784,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'إضافة مكان/نشاط',
|
'places.addPlace': 'إضافة مكان/نشاط',
|
||||||
'places.importGpx': 'استيراد GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||||
'places.gpxError': 'فشل استيراد GPX',
|
'places.gpxError': 'فشل استيراد GPX',
|
||||||
|
'places.importGoogleList': 'قائمة Google',
|
||||||
|
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
|
||||||
|
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
|
||||||
|
'places.googleListError': 'فشل استيراد قائمة Google Maps',
|
||||||
|
'places.viewDetails': 'عرض التفاصيل',
|
||||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||||
'places.all': 'الكل',
|
'places.all': 'الكل',
|
||||||
@@ -762,6 +849,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'حجز',
|
'inspector.addRes': 'حجز',
|
||||||
'inspector.editRes': 'تعديل الحجز',
|
'inspector.editRes': 'تعديل الحجز',
|
||||||
'inspector.participants': 'المشاركون',
|
'inspector.participants': 'المشاركون',
|
||||||
|
'inspector.trackStats': 'بيانات المسار',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'الحجوزات',
|
'reservations.title': 'الحجوزات',
|
||||||
@@ -844,6 +932,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'الميزانية',
|
'budget.title': 'الميزانية',
|
||||||
|
'budget.exportCsv': 'تصدير CSV',
|
||||||
'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد',
|
'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد',
|
||||||
'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك',
|
'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك',
|
||||||
'budget.emptyPlaceholder': 'أدخل اسم الفئة...',
|
'budget.emptyPlaceholder': 'أدخل اسم الفئة...',
|
||||||
@@ -858,6 +947,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'لكل يوم',
|
'budget.table.perDay': 'لكل يوم',
|
||||||
'budget.table.perPersonDay': 'لكل شخص / يوم',
|
'budget.table.perPersonDay': 'لكل شخص / يوم',
|
||||||
'budget.table.note': 'ملاحظة',
|
'budget.table.note': 'ملاحظة',
|
||||||
|
'budget.table.date': 'التاريخ',
|
||||||
'budget.newEntry': 'إدخال جديد',
|
'budget.newEntry': 'إدخال جديد',
|
||||||
'budget.defaultEntry': 'إدخال جديد',
|
'budget.defaultEntry': 'إدخال جديد',
|
||||||
'budget.defaultCategory': 'فئة جديدة',
|
'budget.defaultCategory': 'فئة جديدة',
|
||||||
@@ -1251,6 +1341,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'عنوان خادم Immich',
|
'memories.immichUrl': 'عنوان خادم Immich',
|
||||||
'memories.immichApiKey': 'مفتاح API',
|
'memories.immichApiKey': 'مفتاح API',
|
||||||
'memories.testConnection': 'اختبار الاتصال',
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
'memories.connected': 'متصل',
|
'memories.connected': 'متصل',
|
||||||
'memories.disconnected': 'غير متصل',
|
'memories.disconnected': 'غير متصل',
|
||||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||||
@@ -1260,6 +1351,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.newest': 'الأحدث أولاً',
|
'memories.newest': 'الأحدث أولاً',
|
||||||
'memories.allLocations': 'جميع المواقع',
|
'memories.allLocations': 'جميع المواقع',
|
||||||
'memories.addPhotos': 'إضافة صور',
|
'memories.addPhotos': 'إضافة صور',
|
||||||
|
'memories.linkAlbum': 'ربط ألبوم',
|
||||||
|
'memories.selectAlbum': 'اختيار ألبوم Immich',
|
||||||
|
'memories.noAlbums': 'لم يتم العثور على ألبومات',
|
||||||
|
'memories.syncAlbum': 'مزامنة الألبوم',
|
||||||
|
'memories.unlinkAlbum': 'إلغاء الربط',
|
||||||
|
'memories.photos': 'صور',
|
||||||
'memories.selectPhotos': 'اختيار صور من Immich',
|
'memories.selectPhotos': 'اختيار صور من Immich',
|
||||||
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
||||||
'memories.selected': 'محدد',
|
'memories.selected': 'محدد',
|
||||||
@@ -1291,6 +1388,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'اليوم',
|
'collab.chat.today': 'اليوم',
|
||||||
'collab.chat.yesterday': 'أمس',
|
'collab.chat.yesterday': 'أمس',
|
||||||
'collab.chat.deletedMessage': 'حذف رسالة',
|
'collab.chat.deletedMessage': 'حذف رسالة',
|
||||||
|
'collab.chat.reply': 'رد',
|
||||||
'collab.chat.loadMore': 'تحميل الرسائل الأقدم',
|
'collab.chat.loadMore': 'تحميل الرسائل الأقدم',
|
||||||
'collab.chat.justNow': 'الآن',
|
'collab.chat.justNow': 'الآن',
|
||||||
'collab.chat.minutesAgo': 'منذ {n} د',
|
'collab.chat.minutesAgo': 'منذ {n} د',
|
||||||
@@ -1341,6 +1439,55 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.polls.options': 'الخيارات',
|
'collab.polls.options': 'الخيارات',
|
||||||
'collab.polls.delete': 'حذف',
|
'collab.polls.delete': 'حذف',
|
||||||
'collab.polls.closedSection': 'مغلق',
|
'collab.polls.closedSection': 'مغلق',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'الصلاحيات',
|
||||||
|
'perm.title': 'إعدادات الصلاحيات',
|
||||||
|
'perm.subtitle': 'التحكم في من يمكنه تنفيذ الإجراءات عبر التطبيق',
|
||||||
|
'perm.saved': 'تم حفظ إعدادات الصلاحيات',
|
||||||
|
'perm.resetDefaults': 'إعادة التعيين إلى الافتراضي',
|
||||||
|
'perm.customized': 'مخصص',
|
||||||
|
'perm.level.admin': 'المسؤول فقط',
|
||||||
|
'perm.level.tripOwner': 'مالك الرحلة',
|
||||||
|
'perm.level.tripMember': 'أعضاء الرحلة',
|
||||||
|
'perm.level.everybody': 'الجميع',
|
||||||
|
'perm.cat.trip': 'إدارة الرحلات',
|
||||||
|
'perm.cat.members': 'إدارة الأعضاء',
|
||||||
|
'perm.cat.files': 'الملفات',
|
||||||
|
'perm.cat.content': 'المحتوى والجدول الزمني',
|
||||||
|
'perm.cat.extras': 'الميزانية والتعبئة والتعاون',
|
||||||
|
'perm.action.trip_create': 'إنشاء رحلات',
|
||||||
|
'perm.action.trip_edit': 'تعديل تفاصيل الرحلة',
|
||||||
|
'perm.action.trip_delete': 'حذف الرحلات',
|
||||||
|
'perm.action.trip_archive': 'أرشفة / إلغاء أرشفة الرحلات',
|
||||||
|
'perm.action.trip_cover_upload': 'رفع صورة الغلاف',
|
||||||
|
'perm.action.member_manage': 'إضافة / إزالة الأعضاء',
|
||||||
|
'perm.action.file_upload': 'رفع الملفات',
|
||||||
|
'perm.action.file_edit': 'تعديل بيانات الملف',
|
||||||
|
'perm.action.file_delete': 'حذف الملفات',
|
||||||
|
'perm.action.place_edit': 'إضافة / تعديل / حذف الأماكن',
|
||||||
|
'perm.action.day_edit': 'تعديل الأيام والملاحظات والتعيينات',
|
||||||
|
'perm.action.reservation_edit': 'إدارة الحجوزات',
|
||||||
|
'perm.action.budget_edit': 'إدارة الميزانية',
|
||||||
|
'perm.action.packing_edit': 'إدارة قوائم التعبئة',
|
||||||
|
'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
|
||||||
|
'perm.action.share_manage': 'إدارة روابط المشاركة',
|
||||||
|
'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
|
||||||
|
'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
|
||||||
|
'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
|
||||||
|
'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'من يمكنه رفع أو تغيير صورة الغلاف',
|
||||||
|
'perm.actionHint.member_manage': 'من يمكنه دعوة أو إزالة أعضاء الرحلة',
|
||||||
|
'perm.actionHint.file_upload': 'من يمكنه رفع ملفات إلى رحلة',
|
||||||
|
'perm.actionHint.file_edit': 'من يمكنه تعديل أوصاف الملفات والروابط',
|
||||||
|
'perm.actionHint.file_delete': 'من يمكنه نقل الملفات إلى سلة المهملات أو حذفها نهائياً',
|
||||||
|
'perm.actionHint.place_edit': 'من يمكنه إضافة أو تعديل أو حذف الأماكن',
|
||||||
|
'perm.actionHint.day_edit': 'من يمكنه تعديل الأيام وملاحظات الأيام وتعيينات الأماكن',
|
||||||
|
'perm.actionHint.reservation_edit': 'من يمكنه إنشاء أو تعديل أو حذف الحجوزات',
|
||||||
|
'perm.actionHint.budget_edit': 'من يمكنه إنشاء أو تعديل أو حذف عناصر الميزانية',
|
||||||
|
'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
|
||||||
|
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
|
||||||
|
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ar
|
export default ar
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Editar',
|
'common.edit': 'Editar',
|
||||||
'common.add': 'Adicionar',
|
'common.add': 'Adicionar',
|
||||||
'common.loading': 'Carregando...',
|
'common.loading': 'Carregando...',
|
||||||
|
'common.import': 'Importar',
|
||||||
'common.error': 'Erro',
|
'common.error': 'Erro',
|
||||||
'common.back': 'Voltar',
|
'common.back': 'Voltar',
|
||||||
'common.all': 'Todos',
|
'common.all': 'Todos',
|
||||||
@@ -25,6 +26,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Senha',
|
'common.password': 'Senha',
|
||||||
'common.saving': 'Salvando...',
|
'common.saving': 'Salvando...',
|
||||||
|
'common.saved': 'Salvo',
|
||||||
|
'trips.reminder': 'Lembrete',
|
||||||
|
'trips.reminderNone': 'Nenhum',
|
||||||
|
'trips.reminderDay': 'dia',
|
||||||
|
'trips.reminderDays': 'dias',
|
||||||
|
'trips.reminderCustom': 'Personalizado',
|
||||||
|
'trips.reminderDaysBefore': 'dias antes da partida',
|
||||||
|
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
|
||||||
'common.update': 'Atualizar',
|
'common.update': 'Atualizar',
|
||||||
'common.change': 'Alterar',
|
'common.change': 'Alterar',
|
||||||
'common.uploading': 'Enviando…',
|
'common.uploading': 'Enviando…',
|
||||||
@@ -149,9 +158,26 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
|
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
|
||||||
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
|
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
|
||||||
'settings.notifyWebhook': 'Notificações webhook',
|
'settings.notifyWebhook': 'Notificações webhook',
|
||||||
|
'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.',
|
||||||
|
'settings.notificationsActive': 'Canal ativo',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Os eventos de notificação são configurados pelo administrador.',
|
||||||
|
'admin.notifications.title': 'Notificações',
|
||||||
|
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
|
||||||
|
'admin.notifications.none': 'Desativado',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Eventos de notificação',
|
||||||
|
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
|
||||||
|
'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
|
||||||
|
'admin.notifications.save': 'Salvar configurações de notificação',
|
||||||
|
'admin.notifications.saved': 'Configurações de notificação salvas',
|
||||||
|
'admin.notifications.testWebhook': 'Enviar webhook de teste',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Webhook de teste enviado com sucesso',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste',
|
||||||
'admin.smtp.title': 'E-mail e notificações',
|
'admin.smtp.title': 'E-mail e notificações',
|
||||||
'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.',
|
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
|
||||||
'admin.smtp.testButton': 'Enviar e-mail de teste',
|
'admin.smtp.testButton': 'Enviar e-mail de teste',
|
||||||
|
'admin.webhook.hint': 'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
|
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
|
||||||
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
|
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
|
||||||
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
|
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
|
||||||
@@ -200,7 +226,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Informe a senha atual e a nova',
|
'settings.passwordRequired': 'Informe a senha atual e a nova',
|
||||||
'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
|
'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
|
||||||
'settings.passwordMismatch': 'As senhas não coincidem',
|
'settings.passwordMismatch': 'As senhas não coincidem',
|
||||||
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número',
|
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial',
|
||||||
'settings.passwordChanged': 'Senha alterada com sucesso',
|
'settings.passwordChanged': 'Senha alterada com sucesso',
|
||||||
'settings.deleteAccount': 'Excluir conta',
|
'settings.deleteAccount': 'Excluir conta',
|
||||||
'settings.deleteAccountTitle': 'Excluir sua conta?',
|
'settings.deleteAccountTitle': 'Excluir sua conta?',
|
||||||
@@ -221,6 +247,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Falha no envio',
|
'settings.avatarError': 'Falha no envio',
|
||||||
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
|
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
|
||||||
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.',
|
||||||
|
'settings.mfa.backupTitle': 'Códigos de backup',
|
||||||
|
'settings.mfa.backupDescription': 'Use estes códigos únicos se perder acesso ao app autenticador.',
|
||||||
|
'settings.mfa.backupWarning': 'Salve estes códigos agora. Cada código pode ser usado apenas uma vez.',
|
||||||
|
'settings.mfa.backupCopy': 'Copiar códigos',
|
||||||
|
'settings.mfa.backupDownload': 'Baixar TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Imprimir / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Códigos de backup copiados',
|
||||||
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
|
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
|
||||||
'settings.mfa.disabled': 'O 2FA não está ativado.',
|
'settings.mfa.disabled': 'O 2FA não está ativado.',
|
||||||
'settings.mfa.setup': 'Configurar autenticador',
|
'settings.mfa.setup': 'Configurar autenticador',
|
||||||
@@ -235,6 +269,32 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
|
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
|
||||||
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
|
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
|
||||||
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
|
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
|
||||||
|
'settings.mcp.title': 'Configuração MCP',
|
||||||
|
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||||
|
'settings.mcp.clientConfig': 'Configuração do cliente',
|
||||||
|
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
|
||||||
|
'settings.mcp.copy': 'Copiar',
|
||||||
|
'settings.mcp.copied': 'Copiado!',
|
||||||
|
'settings.mcp.apiTokens': 'Tokens de API',
|
||||||
|
'settings.mcp.createToken': 'Criar novo token',
|
||||||
|
'settings.mcp.noTokens': 'Nenhum token ainda. Crie um para conectar clientes MCP.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Criado em',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Usado em',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Excluir token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Este token deixará de funcionar imediatamente. Qualquer cliente MCP que o utilize perderá o acesso.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Criar token de API',
|
||||||
|
'settings.mcp.modal.tokenName': 'Nome do token',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'ex.: Claude Desktop, Notebook do trabalho',
|
||||||
|
'settings.mcp.modal.creating': 'Criando…',
|
||||||
|
'settings.mcp.modal.create': 'Criar token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token criado',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Este token será exibido apenas uma vez. Copie e guarde agora — não poderá ser recuperado.',
|
||||||
|
'settings.mcp.modal.done': 'Concluído',
|
||||||
|
'settings.mcp.toast.created': 'Token criado',
|
||||||
|
'settings.mcp.toast.createError': 'Falha ao criar token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token excluído',
|
||||||
|
'settings.mcp.toast.deleteError': 'Falha ao excluir token',
|
||||||
|
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.error': 'Falha no login. Verifique suas credenciais.',
|
'login.error': 'Falha no login. Verifique suas credenciais.',
|
||||||
@@ -263,6 +323,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Entrar',
|
'login.signIn': 'Entrar',
|
||||||
'login.createAdmin': 'Criar conta de administrador',
|
'login.createAdmin': 'Criar conta de administrador',
|
||||||
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.',
|
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.',
|
||||||
|
'login.setNewPassword': 'Definir nova senha',
|
||||||
|
'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.',
|
||||||
'login.createAccount': 'Criar conta',
|
'login.createAccount': 'Criar conta',
|
||||||
'login.createAccountHint': 'Cadastre uma nova conta.',
|
'login.createAccountHint': 'Cadastre uma nova conta.',
|
||||||
'login.creating': 'Criando…',
|
'login.creating': 'Criando…',
|
||||||
@@ -289,7 +351,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'As senhas não coincidem',
|
'register.passwordMismatch': 'As senhas não coincidem',
|
||||||
'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres',
|
'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
|
||||||
'register.failed': 'Falha no cadastro',
|
'register.failed': 'Falha no cadastro',
|
||||||
'register.getStarted': 'Começar',
|
'register.getStarted': 'Começar',
|
||||||
'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.',
|
'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.',
|
||||||
@@ -364,6 +426,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Configurações',
|
'admin.tabs.settings': 'Configurações',
|
||||||
'admin.allowRegistration': 'Permitir cadastro',
|
'admin.allowRegistration': 'Permitir cadastro',
|
||||||
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
||||||
|
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
|
||||||
'admin.apiKeys': 'Chaves de API',
|
'admin.apiKeys': 'Chaves de API',
|
||||||
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
|
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
|
||||||
'admin.mapsKey': 'Chave da API Google Maps',
|
'admin.mapsKey': 'Chave da API Google Maps',
|
||||||
@@ -432,17 +496,21 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
|
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
|
||||||
'admin.addons.catalog.collab.name': 'Colab',
|
'admin.addons.catalog.collab.name': 'Colab',
|
||||||
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
|
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
|
||||||
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
|
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
|
||||||
'admin.addons.subtitleAfter': ' experiência.',
|
'admin.addons.subtitleAfter': ' experiência.',
|
||||||
'admin.addons.enabled': 'Ativado',
|
'admin.addons.enabled': 'Ativado',
|
||||||
'admin.addons.disabled': 'Desativado',
|
'admin.addons.disabled': 'Desativado',
|
||||||
'admin.addons.type.trip': 'Viagem',
|
'admin.addons.type.trip': 'Viagem',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Integração',
|
||||||
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
|
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
|
||||||
'admin.addons.globalHint': 'Disponível como seção própria na navegação principal',
|
'admin.addons.globalHint': 'Disponível como seção própria na navegação principal',
|
||||||
'admin.addons.toast.updated': 'Complemento atualizado',
|
'admin.addons.toast.updated': 'Complemento atualizado',
|
||||||
'admin.addons.toast.error': 'Falha ao atualizar complemento',
|
'admin.addons.toast.error': 'Falha ao atualizar complemento',
|
||||||
'admin.addons.noAddons': 'Nenhum complemento disponível',
|
'admin.addons.noAddons': 'Nenhum complemento disponível',
|
||||||
|
'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada',
|
||||||
// Weather info
|
// Weather info
|
||||||
'admin.weather.title': 'Dados meteorológicos',
|
'admin.weather.title': 'Dados meteorológicos',
|
||||||
'admin.weather.badge': 'Desde 24 de março de 2026',
|
'admin.weather.badge': 'Desde 24 de março de 2026',
|
||||||
@@ -601,6 +669,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
||||||
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
||||||
'atlas.addPoi': 'Adicionar lugar',
|
'atlas.addPoi': 'Adicionar lugar',
|
||||||
|
'atlas.searchCountry': 'Buscar um país...',
|
||||||
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
|
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
|
||||||
'atlas.month': 'Mês',
|
'atlas.month': 'Mês',
|
||||||
'atlas.year': 'Ano',
|
'atlas.year': 'Ano',
|
||||||
@@ -609,7 +678,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Estatísticas',
|
'atlas.statsTab': 'Estatísticas',
|
||||||
'atlas.bucketTab': 'Lista de desejos',
|
'atlas.bucketTab': 'Lista de desejos',
|
||||||
'atlas.addBucket': 'Adicionar à lista de desejos',
|
'atlas.addBucket': 'Adicionar à lista de desejos',
|
||||||
'atlas.bucketNamePlaceholder': 'Lugar ou destino...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
|
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
|
||||||
'atlas.bucketEmpty': 'Sua lista de desejos está vazia',
|
'atlas.bucketEmpty': 'Sua lista de desejos está vazia',
|
||||||
'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar',
|
'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar',
|
||||||
@@ -622,7 +690,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.nextTrip': 'Próxima viagem',
|
'atlas.nextTrip': 'Próxima viagem',
|
||||||
'atlas.daysLeft': 'dias restantes',
|
'atlas.daysLeft': 'dias restantes',
|
||||||
'atlas.streak': 'Sequência',
|
'atlas.streak': 'Sequência',
|
||||||
'atlas.year': 'ano',
|
|
||||||
'atlas.years': 'anos',
|
'atlas.years': 'anos',
|
||||||
'atlas.yearInRow': 'ano seguido',
|
'atlas.yearInRow': 'ano seguido',
|
||||||
'atlas.yearsInRow': 'anos seguidos',
|
'atlas.yearsInRow': 'anos seguidos',
|
||||||
@@ -664,6 +731,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.toast.reservationAdded': 'Reserva adicionada',
|
'trip.toast.reservationAdded': 'Reserva adicionada',
|
||||||
'trip.toast.deleted': 'Excluído',
|
'trip.toast.deleted': 'Excluído',
|
||||||
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
||||||
|
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
|
||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.emptyDay': 'Nenhum lugar planejado para este dia',
|
'dayplan.emptyDay': 'Nenhum lugar planejado para este dia',
|
||||||
@@ -698,9 +766,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Adicionar lugar/atividade',
|
'places.addPlace': 'Adicionar lugar/atividade',
|
||||||
'places.importGpx': 'Importar GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lugares importados do GPX',
|
'places.gpxImported': '{count} lugares importados do GPX',
|
||||||
'places.gpxError': 'Falha ao importar GPX',
|
'places.gpxError': 'Falha ao importar GPX',
|
||||||
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
|
||||||
|
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||||
|
'places.googleListError': 'Falha ao importar lista do Google Maps',
|
||||||
|
'places.viewDetails': 'Ver detalhes',
|
||||||
'places.urlResolved': 'Lugar importado da URL',
|
'places.urlResolved': 'Lugar importado da URL',
|
||||||
'places.assignToDay': 'Adicionar a qual dia?',
|
'places.assignToDay': 'Adicionar a qual dia?',
|
||||||
'places.all': 'Todos',
|
'places.all': 'Todos',
|
||||||
@@ -757,6 +830,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Reserva',
|
'inspector.addRes': 'Reserva',
|
||||||
'inspector.editRes': 'Editar reserva',
|
'inspector.editRes': 'Editar reserva',
|
||||||
'inspector.participants': 'Participantes',
|
'inspector.participants': 'Participantes',
|
||||||
|
'inspector.trackStats': 'Dados da trilha',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Reservas',
|
'reservations.title': 'Reservas',
|
||||||
@@ -839,6 +913,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Orçamento',
|
'budget.title': 'Orçamento',
|
||||||
|
'budget.exportCsv': 'Exportar CSV',
|
||||||
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
|
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
|
||||||
'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem',
|
'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem',
|
||||||
'budget.emptyPlaceholder': 'Nome da categoria...',
|
'budget.emptyPlaceholder': 'Nome da categoria...',
|
||||||
@@ -853,6 +928,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Por dia',
|
'budget.table.perDay': 'Por dia',
|
||||||
'budget.table.perPersonDay': 'P. p. / dia',
|
'budget.table.perPersonDay': 'P. p. / dia',
|
||||||
'budget.table.note': 'Obs.',
|
'budget.table.note': 'Obs.',
|
||||||
|
'budget.table.date': 'Data',
|
||||||
'budget.newEntry': 'Novo lançamento',
|
'budget.newEntry': 'Novo lançamento',
|
||||||
'budget.defaultEntry': 'Novo lançamento',
|
'budget.defaultEntry': 'Novo lançamento',
|
||||||
'budget.defaultCategory': 'Nova categoria',
|
'budget.defaultCategory': 'Nova categoria',
|
||||||
@@ -1247,6 +1323,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Hoje',
|
'collab.chat.today': 'Hoje',
|
||||||
'collab.chat.yesterday': 'Ontem',
|
'collab.chat.yesterday': 'Ontem',
|
||||||
'collab.chat.deletedMessage': 'apagou uma mensagem',
|
'collab.chat.deletedMessage': 'apagou uma mensagem',
|
||||||
|
'collab.chat.reply': 'Responder',
|
||||||
'collab.chat.loadMore': 'Carregar mensagens antigas',
|
'collab.chat.loadMore': 'Carregar mensagens antigas',
|
||||||
'collab.chat.justNow': 'agora mesmo',
|
'collab.chat.justNow': 'agora mesmo',
|
||||||
'collab.chat.minutesAgo': 'há {n} min',
|
'collab.chat.minutesAgo': 'há {n} min',
|
||||||
@@ -1315,12 +1392,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'URL do servidor Immich',
|
'memories.immichUrl': 'URL do servidor Immich',
|
||||||
'memories.immichApiKey': 'Chave da API',
|
'memories.immichApiKey': 'Chave da API',
|
||||||
'memories.testConnection': 'Testar conexão',
|
'memories.testConnection': 'Testar conexão',
|
||||||
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'Não conectado',
|
'memories.disconnected': 'Não conectado',
|
||||||
'memories.connectionSuccess': 'Conectado ao Immich',
|
'memories.connectionSuccess': 'Conectado ao Immich',
|
||||||
'memories.connectionError': 'Não foi possível conectar ao Immich',
|
'memories.connectionError': 'Não foi possível conectar ao Immich',
|
||||||
'memories.saved': 'Configurações do Immich salvas',
|
'memories.saved': 'Configurações do Immich salvas',
|
||||||
'memories.addPhotos': 'Adicionar fotos',
|
'memories.addPhotos': 'Adicionar fotos',
|
||||||
|
'memories.linkAlbum': 'Vincular álbum',
|
||||||
|
'memories.selectAlbum': 'Selecionar álbum do Immich',
|
||||||
|
'memories.noAlbums': 'Nenhum álbum encontrado',
|
||||||
|
'memories.syncAlbum': 'Sincronizar álbum',
|
||||||
|
'memories.unlinkAlbum': 'Desvincular',
|
||||||
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': 'Selecionar fotos do Immich',
|
'memories.selectPhotos': 'Selecionar fotos do Immich',
|
||||||
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
|
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
|
||||||
'memories.selected': 'selecionadas',
|
'memories.selected': 'selecionadas',
|
||||||
@@ -1336,6 +1420,69 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
|
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
|
||||||
'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
|
'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
|
||||||
'memories.confirmShareButton': 'Compartilhar fotos',
|
'memories.confirmShareButton': 'Compartilhar fotos',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permissões',
|
||||||
|
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.title': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários',
|
||||||
|
'admin.mcpTokens.owner': 'Proprietário',
|
||||||
|
'admin.mcpTokens.tokenName': 'Nome do Token',
|
||||||
|
'admin.mcpTokens.created': 'Criado',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Último uso',
|
||||||
|
'admin.mcpTokens.never': 'Nunca',
|
||||||
|
'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Excluir Token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token excluído',
|
||||||
|
'admin.mcpTokens.deleteError': 'Falha ao excluir token',
|
||||||
|
'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
|
||||||
|
'perm.title': 'Configurações de Permissões',
|
||||||
|
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
|
||||||
|
'perm.saved': 'Configurações de permissões salvas',
|
||||||
|
'perm.resetDefaults': 'Restaurar padrões',
|
||||||
|
'perm.customized': 'personalizado',
|
||||||
|
'perm.level.admin': 'Apenas administrador',
|
||||||
|
'perm.level.tripOwner': 'Dono da viagem',
|
||||||
|
'perm.level.tripMember': 'Membros da viagem',
|
||||||
|
'perm.level.everybody': 'Todos',
|
||||||
|
'perm.cat.trip': 'Gerenciamento de Viagens',
|
||||||
|
'perm.cat.members': 'Gerenciamento de Membros',
|
||||||
|
'perm.cat.files': 'Arquivos',
|
||||||
|
'perm.cat.content': 'Conteúdo e Cronograma',
|
||||||
|
'perm.cat.extras': 'Orçamento, Bagagem e Colaboração',
|
||||||
|
'perm.action.trip_create': 'Criar viagens',
|
||||||
|
'perm.action.trip_edit': 'Editar detalhes da viagem',
|
||||||
|
'perm.action.trip_delete': 'Excluir viagens',
|
||||||
|
'perm.action.trip_archive': 'Arquivar / desarquivar viagens',
|
||||||
|
'perm.action.trip_cover_upload': 'Enviar imagem de capa',
|
||||||
|
'perm.action.member_manage': 'Adicionar / remover membros',
|
||||||
|
'perm.action.file_upload': 'Enviar arquivos',
|
||||||
|
'perm.action.file_edit': 'Editar metadados do arquivo',
|
||||||
|
'perm.action.file_delete': 'Excluir arquivos',
|
||||||
|
'perm.action.place_edit': 'Adicionar / editar / excluir lugares',
|
||||||
|
'perm.action.day_edit': 'Editar dias, notas e atribuições',
|
||||||
|
'perm.action.reservation_edit': 'Gerenciar reservas',
|
||||||
|
'perm.action.budget_edit': 'Gerenciar orçamento',
|
||||||
|
'perm.action.packing_edit': 'Gerenciar listas de bagagem',
|
||||||
|
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
|
||||||
|
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
|
||||||
|
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
|
||||||
|
'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
|
||||||
|
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
|
||||||
|
'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Quem pode enviar ou alterar a imagem de capa',
|
||||||
|
'perm.actionHint.member_manage': 'Quem pode convidar ou remover membros da viagem',
|
||||||
|
'perm.actionHint.file_upload': 'Quem pode enviar arquivos para uma viagem',
|
||||||
|
'perm.actionHint.file_edit': 'Quem pode editar descrições e links dos arquivos',
|
||||||
|
'perm.actionHint.file_delete': 'Quem pode mover arquivos para a lixeira ou excluí-los permanentemente',
|
||||||
|
'perm.actionHint.place_edit': 'Quem pode adicionar, editar ou excluir lugares',
|
||||||
|
'perm.actionHint.day_edit': 'Quem pode editar dias, notas dos dias e atribuições de lugares',
|
||||||
|
'perm.actionHint.reservation_edit': 'Quem pode criar, editar ou excluir reservas',
|
||||||
|
'perm.actionHint.budget_edit': 'Quem pode criar, editar ou excluir itens do orçamento',
|
||||||
|
'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
|
||||||
|
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
|
||||||
|
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default br
|
export default br
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Upravit',
|
'common.edit': 'Upravit',
|
||||||
'common.add': 'Přidat',
|
'common.add': 'Přidat',
|
||||||
'common.loading': 'Načítání...',
|
'common.loading': 'Načítání...',
|
||||||
|
'common.import': 'Importovat',
|
||||||
'common.error': 'Chyba',
|
'common.error': 'Chyba',
|
||||||
'common.back': 'Zpět',
|
'common.back': 'Zpět',
|
||||||
'common.all': 'Vše',
|
'common.all': 'Vše',
|
||||||
@@ -25,6 +26,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Heslo',
|
'common.password': 'Heslo',
|
||||||
'common.saving': 'Ukládání...',
|
'common.saving': 'Ukládání...',
|
||||||
|
'common.saved': 'Uloženo',
|
||||||
|
'trips.reminder': 'Připomínka',
|
||||||
|
'trips.reminderNone': 'Žádná',
|
||||||
|
'trips.reminderDay': 'den',
|
||||||
|
'trips.reminderDays': 'dní',
|
||||||
|
'trips.reminderCustom': 'Vlastní',
|
||||||
|
'trips.reminderDaysBefore': 'dní před odjezdem',
|
||||||
|
'trips.reminderDisabledHint': 'Připomínky výletů jsou zakázány. Povolte je v Správa > Nastavení > Oznámení.',
|
||||||
'common.update': 'Aktualizovat',
|
'common.update': 'Aktualizovat',
|
||||||
'common.change': 'Změnit',
|
'common.change': 'Změnit',
|
||||||
'common.uploading': 'Nahrávání…',
|
'common.uploading': 'Nahrávání…',
|
||||||
@@ -150,8 +159,36 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
|
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
|
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
|
||||||
'settings.notifyWebhook': 'Webhook oznámení',
|
'settings.notifyWebhook': 'Webhook oznámení',
|
||||||
|
'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.',
|
||||||
|
'settings.notificationsActive': 'Aktivní kanál',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Události oznámení jsou konfigurovány administrátorem.',
|
||||||
'settings.on': 'Zapnuto',
|
'settings.on': 'Zapnuto',
|
||||||
'settings.off': 'Vypnuto',
|
'settings.off': 'Vypnuto',
|
||||||
|
'settings.mcp.title': 'Konfigurace MCP',
|
||||||
|
'settings.mcp.endpoint': 'MCP endpoint',
|
||||||
|
'settings.mcp.clientConfig': 'Konfigurace klienta',
|
||||||
|
'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
|
||||||
|
'settings.mcp.copy': 'Kopírovat',
|
||||||
|
'settings.mcp.copied': 'Zkopírováno!',
|
||||||
|
'settings.mcp.apiTokens': 'API tokeny',
|
||||||
|
'settings.mcp.createToken': 'Vytvořit nový token',
|
||||||
|
'settings.mcp.noTokens': 'Zatím žádné tokeny. Vytvořte jeden pro připojení MCP klientů.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Vytvořen',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Použit',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Smazat token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Tento token přestane okamžitě fungovat. Všichni MCP klienti, kteří ho používají, ztratí přístup.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Vytvořit API token',
|
||||||
|
'settings.mcp.modal.tokenName': 'Název tokenu',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'např. Claude Desktop, Pracovní notebook',
|
||||||
|
'settings.mcp.modal.creating': 'Vytváření…',
|
||||||
|
'settings.mcp.modal.create': 'Vytvořit token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token vytvořen',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Tento token bude zobrazen pouze jednou. Zkopírujte a uložte ho nyní — nelze ho obnovit.',
|
||||||
|
'settings.mcp.modal.done': 'Hotovo',
|
||||||
|
'settings.mcp.toast.created': 'Token vytvořen',
|
||||||
|
'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token smazán',
|
||||||
|
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
|
||||||
'settings.account': 'Účet',
|
'settings.account': 'Účet',
|
||||||
'settings.username': 'Uživatelské jméno',
|
'settings.username': 'Uživatelské jméno',
|
||||||
'settings.email': 'E-mail',
|
'settings.email': 'E-mail',
|
||||||
@@ -167,7 +204,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
|
'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
|
||||||
'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
|
'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
|
||||||
'settings.passwordMismatch': 'Hesla se neshodují',
|
'settings.passwordMismatch': 'Hesla se neshodují',
|
||||||
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici',
|
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak',
|
||||||
'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
|
'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
|
||||||
'settings.deleteAccount': 'Smazat účet',
|
'settings.deleteAccount': 'Smazat účet',
|
||||||
'settings.deleteAccountTitle': 'Smazat váš účet?',
|
'settings.deleteAccountTitle': 'Smazat váš účet?',
|
||||||
@@ -188,6 +225,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Nahrávání se nezdařilo',
|
'settings.avatarError': 'Nahrávání se nezdařilo',
|
||||||
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
|
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
|
||||||
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
|
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.',
|
||||||
|
'settings.mfa.backupTitle': 'Záložní kódy',
|
||||||
|
'settings.mfa.backupDescription': 'Použijte tyto jednorázové kódy, pokud ztratíte přístup k autentizační aplikaci.',
|
||||||
|
'settings.mfa.backupWarning': 'Uložte si je hned. Každý kód lze použít pouze jednou.',
|
||||||
|
'settings.mfa.backupCopy': 'Kopírovat kódy',
|
||||||
|
'settings.mfa.backupDownload': 'Stáhnout TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Tisk / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Záložní kódy zkopírovány',
|
||||||
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
|
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
|
||||||
'settings.mfa.disabled': '2FA není aktivní.',
|
'settings.mfa.disabled': '2FA není aktivní.',
|
||||||
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
|
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
|
||||||
@@ -202,9 +247,23 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
|
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
|
||||||
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
|
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
|
||||||
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
|
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
|
||||||
|
'admin.notifications.title': 'Oznámení',
|
||||||
|
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
|
||||||
|
'admin.notifications.none': 'Vypnuto',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Události oznámení',
|
||||||
|
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
|
||||||
|
'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
|
||||||
|
'admin.notifications.save': 'Uložit nastavení oznámení',
|
||||||
|
'admin.notifications.saved': 'Nastavení oznámení uloženo',
|
||||||
|
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Testovací webhook úspěšně odeslán',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Odeslání testovacího webhooku se nezdařilo',
|
||||||
'admin.smtp.title': 'E-mail a oznámení',
|
'admin.smtp.title': 'E-mail a oznámení',
|
||||||
'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.',
|
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
|
||||||
'admin.smtp.testButton': 'Odeslat testovací e-mail',
|
'admin.smtp.testButton': 'Odeslat testovací e-mail',
|
||||||
|
'admin.webhook.hint': 'Odesílat oznámení na externí webhook (Discord, Slack atd.).',
|
||||||
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
|
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
|
||||||
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
|
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
|
||||||
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
|
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
|
||||||
@@ -264,6 +323,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Přihlásit se',
|
'login.signIn': 'Přihlásit se',
|
||||||
'login.createAdmin': 'Vytvořit účet administrátora',
|
'login.createAdmin': 'Vytvořit účet administrátora',
|
||||||
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
|
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
|
||||||
|
'login.setNewPassword': 'Nastavit nové heslo',
|
||||||
|
'login.setNewPasswordHint': 'Před pokračováním musíte změnit heslo.',
|
||||||
'login.createAccount': 'Vytvořit účet',
|
'login.createAccount': 'Vytvořit účet',
|
||||||
'login.createAccountHint': 'Zaregistrujte si nový účet.',
|
'login.createAccountHint': 'Zaregistrujte si nový účet.',
|
||||||
'login.creating': 'Vytváření…',
|
'login.creating': 'Vytváření…',
|
||||||
@@ -290,7 +351,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Registrace (Register)
|
// Registrace (Register)
|
||||||
'register.passwordMismatch': 'Hesla se neshodují',
|
'register.passwordMismatch': 'Hesla se neshodují',
|
||||||
'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků',
|
'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
|
||||||
'register.failed': 'Registrace se nezdařila',
|
'register.failed': 'Registrace se nezdařila',
|
||||||
'register.getStarted': 'Začínáme',
|
'register.getStarted': 'Začínáme',
|
||||||
'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',
|
'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',
|
||||||
@@ -365,6 +426,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Nastavení',
|
'admin.tabs.settings': 'Nastavení',
|
||||||
'admin.allowRegistration': 'Povolit registraci',
|
'admin.allowRegistration': 'Povolit registraci',
|
||||||
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
||||||
|
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
|
||||||
'admin.apiKeys': 'API klíče',
|
'admin.apiKeys': 'API klíče',
|
||||||
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
|
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
|
||||||
'admin.mapsKey': 'Google Maps API klíč',
|
'admin.mapsKey': 'Google Maps API klíč',
|
||||||
@@ -419,8 +482,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.addons': 'Doplňky',
|
'admin.tabs.addons': 'Doplňky',
|
||||||
'admin.addons.title': 'Doplňky',
|
'admin.addons.title': 'Doplňky',
|
||||||
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
|
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
|
||||||
'admin.addons.catalog.memories.name': 'Vzpomínky',
|
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Sdílená fotoalba pro každou cestu',
|
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
|
||||||
'admin.addons.catalog.packing.name': 'Balení',
|
'admin.addons.catalog.packing.name': 'Balení',
|
||||||
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
|
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
|
||||||
'admin.addons.catalog.budget.name': 'Rozpočet',
|
'admin.addons.catalog.budget.name': 'Rozpočet',
|
||||||
@@ -437,13 +500,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.disabled': 'Zakázáno',
|
'admin.addons.disabled': 'Zakázáno',
|
||||||
'admin.addons.type.trip': 'Cesta',
|
'admin.addons.type.trip': 'Cesta',
|
||||||
'admin.addons.type.global': 'Globální',
|
'admin.addons.type.global': 'Globální',
|
||||||
|
'admin.addons.type.integration': 'Integrace',
|
||||||
'admin.addons.tripHint': 'Dostupné jako karta v rámci každé cesty',
|
'admin.addons.tripHint': 'Dostupné jako karta v rámci každé cesty',
|
||||||
'admin.addons.globalHint': 'Dostupné jako samostatná sekce v hlavní navigaci',
|
'admin.addons.globalHint': 'Dostupné jako samostatná sekce v hlavní navigaci',
|
||||||
|
'admin.addons.integrationHint': 'Backendové služby a API integrace bez vlastní stránky',
|
||||||
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
|
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
|
||||||
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
|
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
|
||||||
'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
|
'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
|
||||||
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
|
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
|
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů',
|
||||||
'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
|
'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
|
||||||
'admin.addons.subtitleAfter': '.',
|
'admin.addons.subtitleAfter': '.',
|
||||||
|
|
||||||
@@ -461,6 +528,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.audit.col.ip': 'IP',
|
'admin.audit.col.ip': 'IP',
|
||||||
'admin.audit.col.details': 'Detaily',
|
'admin.audit.col.details': 'Detaily',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP tokeny',
|
||||||
|
'admin.mcpTokens.title': 'MCP tokeny',
|
||||||
|
'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů',
|
||||||
|
'admin.mcpTokens.owner': 'Vlastník',
|
||||||
|
'admin.mcpTokens.tokenName': 'Název tokenu',
|
||||||
|
'admin.mcpTokens.created': 'Vytvořen',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Naposledy použit',
|
||||||
|
'admin.mcpTokens.never': 'Nikdy',
|
||||||
|
'admin.mcpTokens.empty': 'Zatím nebyly vytvořeny žádné MCP tokeny',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Smazat token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Tento token bude okamžitě zneplatněn. Uživatel ztratí MCP přístup přes tento token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token smazán',
|
||||||
|
'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
|
||||||
|
'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
'admin.github.title': 'Historie verzí',
|
'admin.github.title': 'Historie verzí',
|
||||||
@@ -612,7 +695,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Statistiky',
|
'atlas.statsTab': 'Statistiky',
|
||||||
'atlas.bucketTab': 'Bucket List',
|
'atlas.bucketTab': 'Bucket List',
|
||||||
'atlas.addBucket': 'Přidat na Bucket List',
|
'atlas.addBucket': 'Přidat na Bucket List',
|
||||||
'atlas.bucketNamePlaceholder': 'Místo nebo destinace...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)',
|
'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)',
|
||||||
'atlas.bucketEmpty': 'Váš seznam přání je prázdný',
|
'atlas.bucketEmpty': 'Váš seznam přání je prázdný',
|
||||||
'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit',
|
'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit',
|
||||||
@@ -655,6 +737,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.tabs.budget': 'Rozpočet',
|
'trip.tabs.budget': 'Rozpočet',
|
||||||
'trip.tabs.files': 'Soubory',
|
'trip.tabs.files': 'Soubory',
|
||||||
'trip.loading': 'Načítání cesty...',
|
'trip.loading': 'Načítání cesty...',
|
||||||
|
'trip.loadingPhotos': 'Načítání fotek míst...',
|
||||||
'trip.mobilePlan': 'Plán',
|
'trip.mobilePlan': 'Plán',
|
||||||
'trip.mobilePlaces': 'Místa',
|
'trip.mobilePlaces': 'Místa',
|
||||||
'trip.toast.placeUpdated': 'Místo bylo aktualizováno',
|
'trip.toast.placeUpdated': 'Místo bylo aktualizováno',
|
||||||
@@ -701,10 +784,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Boční panel míst (Places Sidebar)
|
// Boční panel míst (Places Sidebar)
|
||||||
'places.addPlace': 'Přidat místo/aktivitu',
|
'places.addPlace': 'Přidat místo/aktivitu',
|
||||||
'places.importGpx': 'Importovat GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} míst importováno z GPX',
|
'places.gpxImported': '{count} míst importováno z GPX',
|
||||||
'places.urlResolved': 'Místo importováno z URL',
|
'places.urlResolved': 'Místo importováno z URL',
|
||||||
'places.gpxError': 'Import GPX se nezdařil',
|
'places.gpxError': 'Import GPX se nezdařil',
|
||||||
|
'places.importGoogleList': 'Google Seznam',
|
||||||
|
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
|
||||||
|
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
|
||||||
|
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
|
||||||
|
'places.viewDetails': 'Zobrazit detaily',
|
||||||
'places.assignToDay': 'Přidat do kterého dne?',
|
'places.assignToDay': 'Přidat do kterého dne?',
|
||||||
'places.all': 'Vše',
|
'places.all': 'Vše',
|
||||||
'places.unplanned': 'Nezařazené',
|
'places.unplanned': 'Nezařazené',
|
||||||
@@ -761,6 +849,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Rezervace',
|
'inspector.addRes': 'Rezervace',
|
||||||
'inspector.editRes': 'Upravit rezervaci',
|
'inspector.editRes': 'Upravit rezervaci',
|
||||||
'inspector.participants': 'Účastníci',
|
'inspector.participants': 'Účastníci',
|
||||||
|
'inspector.trackStats': 'Data trasy',
|
||||||
|
|
||||||
// Rezervace (Reservations)
|
// Rezervace (Reservations)
|
||||||
'reservations.title': 'Rezervace',
|
'reservations.title': 'Rezervace',
|
||||||
@@ -843,6 +932,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Rozpočet (Budget)
|
// Rozpočet (Budget)
|
||||||
'budget.title': 'Rozpočet',
|
'budget.title': 'Rozpočet',
|
||||||
|
'budget.exportCsv': 'Exportovat CSV',
|
||||||
'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet',
|
'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet',
|
||||||
'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu',
|
'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu',
|
||||||
'budget.emptyPlaceholder': 'Zadejte název kategorie...',
|
'budget.emptyPlaceholder': 'Zadejte název kategorie...',
|
||||||
@@ -857,6 +947,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Za den',
|
'budget.table.perDay': 'Za den',
|
||||||
'budget.table.perPersonDay': 'Os. / den',
|
'budget.table.perPersonDay': 'Os. / den',
|
||||||
'budget.table.note': 'Poznámka',
|
'budget.table.note': 'Poznámka',
|
||||||
|
'budget.table.date': 'Datum',
|
||||||
'budget.newEntry': 'Nová položka',
|
'budget.newEntry': 'Nová položka',
|
||||||
'budget.defaultEntry': 'Nová položka',
|
'budget.defaultEntry': 'Nová položka',
|
||||||
'budget.defaultCategory': 'Nová kategorie',
|
'budget.defaultCategory': 'Nová kategorie',
|
||||||
@@ -1250,12 +1341,19 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'URL serveru Immich',
|
'memories.immichUrl': 'URL serveru Immich',
|
||||||
'memories.immichApiKey': 'API klíč',
|
'memories.immichApiKey': 'API klíč',
|
||||||
'memories.testConnection': 'Otestovat připojení',
|
'memories.testConnection': 'Otestovat připojení',
|
||||||
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
'memories.connected': 'Připojeno',
|
'memories.connected': 'Připojeno',
|
||||||
'memories.disconnected': 'Nepřipojeno',
|
'memories.disconnected': 'Nepřipojeno',
|
||||||
'memories.connectionSuccess': 'Připojeno k Immich',
|
'memories.connectionSuccess': 'Připojeno k Immich',
|
||||||
'memories.connectionError': 'Nepodařilo se připojit k Immich',
|
'memories.connectionError': 'Nepodařilo se připojit k Immich',
|
||||||
'memories.saved': 'Nastavení Immich uloženo',
|
'memories.saved': 'Nastavení Immich uloženo',
|
||||||
'memories.addPhotos': 'Přidat fotky',
|
'memories.addPhotos': 'Přidat fotky',
|
||||||
|
'memories.linkAlbum': 'Propojit album',
|
||||||
|
'memories.selectAlbum': 'Vybrat album z Immich',
|
||||||
|
'memories.noAlbums': 'Žádná alba nenalezena',
|
||||||
|
'memories.syncAlbum': 'Synchronizovat album',
|
||||||
|
'memories.unlinkAlbum': 'Odpojit',
|
||||||
|
'memories.photos': 'fotek',
|
||||||
'memories.selectPhotos': 'Vybrat fotky z Immich',
|
'memories.selectPhotos': 'Vybrat fotky z Immich',
|
||||||
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
|
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
|
||||||
'memories.selected': 'vybráno',
|
'memories.selected': 'vybráno',
|
||||||
@@ -1290,6 +1388,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Dnes',
|
'collab.chat.today': 'Dnes',
|
||||||
'collab.chat.yesterday': 'Včera',
|
'collab.chat.yesterday': 'Včera',
|
||||||
'collab.chat.deletedMessage': 'smazal zprávu',
|
'collab.chat.deletedMessage': 'smazal zprávu',
|
||||||
|
'collab.chat.reply': 'Odpovědět',
|
||||||
'collab.chat.loadMore': 'Načíst starší zprávy',
|
'collab.chat.loadMore': 'Načíst starší zprávy',
|
||||||
'collab.chat.justNow': 'právě teď',
|
'collab.chat.justNow': 'právě teď',
|
||||||
'collab.chat.minutesAgo': 'před {n} min',
|
'collab.chat.minutesAgo': 'před {n} min',
|
||||||
@@ -1340,6 +1439,55 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.polls.options': 'Možnosti',
|
'collab.polls.options': 'Možnosti',
|
||||||
'collab.polls.delete': 'Smazat',
|
'collab.polls.delete': 'Smazat',
|
||||||
'collab.polls.closedSection': 'Uzavřené',
|
'collab.polls.closedSection': 'Uzavřené',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Oprávnění',
|
||||||
|
'perm.title': 'Nastavení oprávnění',
|
||||||
|
'perm.subtitle': 'Určete, kdo může provádět akce v aplikaci',
|
||||||
|
'perm.saved': 'Nastavení oprávnění uloženo',
|
||||||
|
'perm.resetDefaults': 'Obnovit výchozí',
|
||||||
|
'perm.customized': 'upraveno',
|
||||||
|
'perm.level.admin': 'Pouze administrátor',
|
||||||
|
'perm.level.tripOwner': 'Vlastník výletu',
|
||||||
|
'perm.level.tripMember': 'Členové výletu',
|
||||||
|
'perm.level.everybody': 'Všichni',
|
||||||
|
'perm.cat.trip': 'Správa výletů',
|
||||||
|
'perm.cat.members': 'Správa členů',
|
||||||
|
'perm.cat.files': 'Soubory',
|
||||||
|
'perm.cat.content': 'Obsah a plán',
|
||||||
|
'perm.cat.extras': 'Rozpočet, balení a spolupráce',
|
||||||
|
'perm.action.trip_create': 'Vytvářet výlety',
|
||||||
|
'perm.action.trip_edit': 'Upravit detaily výletu',
|
||||||
|
'perm.action.trip_delete': 'Smazat výlety',
|
||||||
|
'perm.action.trip_archive': 'Archivovat / odarchivovat výlety',
|
||||||
|
'perm.action.trip_cover_upload': 'Nahrát titulní obrázek',
|
||||||
|
'perm.action.member_manage': 'Přidat / odebrat členy',
|
||||||
|
'perm.action.file_upload': 'Nahrát soubory',
|
||||||
|
'perm.action.file_edit': 'Upravit metadata souborů',
|
||||||
|
'perm.action.file_delete': 'Smazat soubory',
|
||||||
|
'perm.action.place_edit': 'Přidat / upravit / smazat místa',
|
||||||
|
'perm.action.day_edit': 'Upravit dny, poznámky a přiřazení',
|
||||||
|
'perm.action.reservation_edit': 'Spravovat rezervace',
|
||||||
|
'perm.action.budget_edit': 'Spravovat rozpočet',
|
||||||
|
'perm.action.packing_edit': 'Spravovat seznamy balení',
|
||||||
|
'perm.action.collab_edit': 'Spolupráce (poznámky, hlasování, chat)',
|
||||||
|
'perm.action.share_manage': 'Spravovat odkazy ke sdílení',
|
||||||
|
'perm.actionHint.trip_create': 'Kdo může vytvářet nové výlety',
|
||||||
|
'perm.actionHint.trip_edit': 'Kdo může měnit název, data, popis a měnu výletu',
|
||||||
|
'perm.actionHint.trip_delete': 'Kdo může trvale smazat výlet',
|
||||||
|
'perm.actionHint.trip_archive': 'Kdo může archivovat nebo odarchivovat výlet',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Kdo může nahrát nebo změnit titulní obrázek',
|
||||||
|
'perm.actionHint.member_manage': 'Kdo může pozvat nebo odebrat členy výletu',
|
||||||
|
'perm.actionHint.file_upload': 'Kdo může nahrávat soubory k výletu',
|
||||||
|
'perm.actionHint.file_edit': 'Kdo může upravovat popisy a odkazy souborů',
|
||||||
|
'perm.actionHint.file_delete': 'Kdo může přesunout soubory do koše nebo je trvale smazat',
|
||||||
|
'perm.actionHint.place_edit': 'Kdo může přidávat, upravovat nebo mazat místa',
|
||||||
|
'perm.actionHint.day_edit': 'Kdo může upravovat dny, poznámky ke dnům a přiřazení míst',
|
||||||
|
'perm.actionHint.reservation_edit': 'Kdo může vytvářet, upravovat nebo mazat rezervace',
|
||||||
|
'perm.actionHint.budget_edit': 'Kdo může vytvářet, upravovat nebo mazat položky rozpočtu',
|
||||||
|
'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky',
|
||||||
|
'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
|
||||||
|
'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default cs
|
export default cs
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Bearbeiten',
|
'common.edit': 'Bearbeiten',
|
||||||
'common.add': 'Hinzufügen',
|
'common.add': 'Hinzufügen',
|
||||||
'common.loading': 'Laden...',
|
'common.loading': 'Laden...',
|
||||||
|
'common.import': 'Importieren',
|
||||||
'common.error': 'Fehler',
|
'common.error': 'Fehler',
|
||||||
'common.back': 'Zurück',
|
'common.back': 'Zurück',
|
||||||
'common.all': 'Alle',
|
'common.all': 'Alle',
|
||||||
@@ -25,6 +26,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'E-Mail',
|
'common.email': 'E-Mail',
|
||||||
'common.password': 'Passwort',
|
'common.password': 'Passwort',
|
||||||
'common.saving': 'Speichern...',
|
'common.saving': 'Speichern...',
|
||||||
|
'common.saved': 'Gespeichert',
|
||||||
|
'trips.reminder': 'Erinnerung',
|
||||||
|
'trips.reminderNone': 'Keine',
|
||||||
|
'trips.reminderDay': 'Tag',
|
||||||
|
'trips.reminderDays': 'Tage',
|
||||||
|
'trips.reminderCustom': 'Benutzerdefiniert',
|
||||||
|
'trips.reminderDaysBefore': 'Tage vor Abreise',
|
||||||
|
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
|
||||||
'common.update': 'Aktualisieren',
|
'common.update': 'Aktualisieren',
|
||||||
'common.change': 'Ändern',
|
'common.change': 'Ändern',
|
||||||
'common.uploading': 'Hochladen…',
|
'common.uploading': 'Hochladen…',
|
||||||
@@ -149,9 +158,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
||||||
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
||||||
|
'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
|
||||||
|
'settings.notificationsActive': 'Aktiver Kanal',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.',
|
||||||
|
'admin.notifications.title': 'Benachrichtigungen',
|
||||||
|
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
|
||||||
|
'admin.notifications.none': 'Deaktiviert',
|
||||||
|
'admin.notifications.email': 'E-Mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Benachrichtigungsereignisse',
|
||||||
|
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
|
||||||
|
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
|
||||||
|
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
|
||||||
|
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
|
||||||
|
'admin.notifications.testWebhook': 'Test-Webhook senden',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen',
|
||||||
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||||
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
|
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
|
||||||
'admin.smtp.testButton': 'Test-E-Mail senden',
|
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||||
|
'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
|
||||||
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||||
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||||
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||||
@@ -185,6 +211,31 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'share.permCollab': 'Chat',
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'An',
|
'settings.on': 'An',
|
||||||
'settings.off': 'Aus',
|
'settings.off': 'Aus',
|
||||||
|
'settings.mcp.title': 'MCP-Konfiguration',
|
||||||
|
'settings.mcp.endpoint': 'MCP-Endpunkt',
|
||||||
|
'settings.mcp.clientConfig': 'Client-Konfiguration',
|
||||||
|
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
|
||||||
|
'settings.mcp.copy': 'Kopieren',
|
||||||
|
'settings.mcp.copied': 'Kopiert!',
|
||||||
|
'settings.mcp.apiTokens': 'API-Tokens',
|
||||||
|
'settings.mcp.createToken': 'Neuen Token erstellen',
|
||||||
|
'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Erstellt',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Verwendet',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Token löschen',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.',
|
||||||
|
'settings.mcp.modal.createTitle': 'API-Token erstellen',
|
||||||
|
'settings.mcp.modal.tokenName': 'Token-Name',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop',
|
||||||
|
'settings.mcp.modal.creating': 'Wird erstellt…',
|
||||||
|
'settings.mcp.modal.create': 'Token erstellen',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token erstellt',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.',
|
||||||
|
'settings.mcp.modal.done': 'Fertig',
|
||||||
|
'settings.mcp.toast.created': 'Token erstellt',
|
||||||
|
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
|
||||||
|
'settings.mcp.toast.deleted': 'Token gelöscht',
|
||||||
|
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
'settings.username': 'Benutzername',
|
'settings.username': 'Benutzername',
|
||||||
'settings.email': 'E-Mail',
|
'settings.email': 'E-Mail',
|
||||||
@@ -192,6 +243,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.roleAdmin': 'Administrator',
|
'settings.roleAdmin': 'Administrator',
|
||||||
'settings.oidcLinked': 'Verknüpft mit',
|
'settings.oidcLinked': 'Verknüpft mit',
|
||||||
'settings.changePassword': 'Passwort ändern',
|
'settings.changePassword': 'Passwort ändern',
|
||||||
|
'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.',
|
||||||
'settings.currentPassword': 'Aktuelles Passwort',
|
'settings.currentPassword': 'Aktuelles Passwort',
|
||||||
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
|
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
|
||||||
'settings.newPassword': 'Neues Passwort',
|
'settings.newPassword': 'Neues Passwort',
|
||||||
@@ -200,7 +252,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
||||||
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
|
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten',
|
||||||
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||||
'settings.deleteAccount': 'Löschen',
|
'settings.deleteAccount': 'Löschen',
|
||||||
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
||||||
@@ -221,6 +273,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Fehler beim Hochladen',
|
'settings.avatarError': 'Fehler beim Hochladen',
|
||||||
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||||
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
|
||||||
|
'settings.mfa.backupTitle': 'Backup-Codes',
|
||||||
|
'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.',
|
||||||
|
'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.',
|
||||||
|
'settings.mfa.backupCopy': 'Codes kopieren',
|
||||||
|
'settings.mfa.backupDownload': 'TXT herunterladen',
|
||||||
|
'settings.mfa.backupPrint': 'Drucken / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Backup-Codes kopiert',
|
||||||
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||||
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||||
'settings.mfa.setup': 'Authenticator einrichten',
|
'settings.mfa.setup': 'Authenticator einrichten',
|
||||||
@@ -263,6 +323,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Anmelden',
|
'login.signIn': 'Anmelden',
|
||||||
'login.createAdmin': 'Admin-Konto erstellen',
|
'login.createAdmin': 'Admin-Konto erstellen',
|
||||||
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
|
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
|
||||||
|
'login.setNewPassword': 'Neues Passwort festlegen',
|
||||||
|
'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.',
|
||||||
'login.createAccount': 'Konto erstellen',
|
'login.createAccount': 'Konto erstellen',
|
||||||
'login.createAccountHint': 'Neues Konto registrieren.',
|
'login.createAccountHint': 'Neues Konto registrieren.',
|
||||||
'login.creating': 'Erstelle…',
|
'login.creating': 'Erstelle…',
|
||||||
@@ -289,7 +351,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
|
'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
'register.failed': 'Registrierung fehlgeschlagen',
|
'register.failed': 'Registrierung fehlgeschlagen',
|
||||||
'register.getStarted': 'Jetzt starten',
|
'register.getStarted': 'Jetzt starten',
|
||||||
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
||||||
@@ -365,6 +427,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Einstellungen',
|
'admin.tabs.settings': 'Einstellungen',
|
||||||
'admin.allowRegistration': 'Registrierung erlauben',
|
'admin.allowRegistration': 'Registrierung erlauben',
|
||||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||||
|
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
|
||||||
|
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
|
||||||
'admin.apiKeys': 'API-Schlüssel',
|
'admin.apiKeys': 'API-Schlüssel',
|
||||||
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
@@ -433,14 +497,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration',
|
||||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||||
'admin.addons.enabled': 'Aktiviert',
|
'admin.addons.enabled': 'Aktiviert',
|
||||||
'admin.addons.disabled': 'Deaktiviert',
|
'admin.addons.disabled': 'Deaktiviert',
|
||||||
'admin.addons.type.trip': 'Trip',
|
'admin.addons.type.trip': 'Trip',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Integration',
|
||||||
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
|
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
|
||||||
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
|
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
|
||||||
|
'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite',
|
||||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||||
@@ -456,6 +524,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||||
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP-Tokens',
|
||||||
|
'admin.mcpTokens.title': 'MCP-Tokens',
|
||||||
|
'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten',
|
||||||
|
'admin.mcpTokens.owner': 'Besitzer',
|
||||||
|
'admin.mcpTokens.tokenName': 'Token-Name',
|
||||||
|
'admin.mcpTokens.created': 'Erstellt',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Zuletzt verwendet',
|
||||||
|
'admin.mcpTokens.never': 'Nie',
|
||||||
|
'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Token löschen',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
|
||||||
|
'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
|
||||||
|
'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
@@ -601,6 +685,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||||
'atlas.addToBucket': 'Zur Bucket List',
|
'atlas.addToBucket': 'Zur Bucket List',
|
||||||
'atlas.addPoi': 'Ort hinzufügen',
|
'atlas.addPoi': 'Ort hinzufügen',
|
||||||
|
'atlas.searchCountry': 'Land suchen...',
|
||||||
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||||
'atlas.month': 'Monat',
|
'atlas.month': 'Monat',
|
||||||
'atlas.year': 'Jahr',
|
'atlas.year': 'Jahr',
|
||||||
@@ -609,7 +694,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Statistik',
|
'atlas.statsTab': 'Statistik',
|
||||||
'atlas.bucketTab': 'Bucket List',
|
'atlas.bucketTab': 'Bucket List',
|
||||||
'atlas.addBucket': 'Zur Bucket List hinzufügen',
|
'atlas.addBucket': 'Zur Bucket List hinzufügen',
|
||||||
'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
|
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
|
||||||
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
|
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
|
||||||
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
|
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
|
||||||
@@ -622,7 +706,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.nextTrip': 'Nächster Trip',
|
'atlas.nextTrip': 'Nächster Trip',
|
||||||
'atlas.daysLeft': 'Tage',
|
'atlas.daysLeft': 'Tage',
|
||||||
'atlas.streak': 'Streak',
|
'atlas.streak': 'Streak',
|
||||||
'atlas.year': 'Jahr',
|
|
||||||
'atlas.years': 'Jahre',
|
'atlas.years': 'Jahre',
|
||||||
'atlas.yearInRow': 'Jahr in Folge',
|
'atlas.yearInRow': 'Jahr in Folge',
|
||||||
'atlas.yearsInRow': 'Jahre in Folge',
|
'atlas.yearsInRow': 'Jahre in Folge',
|
||||||
@@ -652,6 +735,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Dateien',
|
'trip.tabs.files': 'Dateien',
|
||||||
'trip.loading': 'Reise wird geladen...',
|
'trip.loading': 'Reise wird geladen...',
|
||||||
|
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
|
||||||
'trip.mobilePlan': 'Planung',
|
'trip.mobilePlan': 'Planung',
|
||||||
'trip.mobilePlaces': 'Orte',
|
'trip.mobilePlaces': 'Orte',
|
||||||
'trip.toast.placeUpdated': 'Ort aktualisiert',
|
'trip.toast.placeUpdated': 'Ort aktualisiert',
|
||||||
@@ -698,10 +782,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||||
'places.importGpx': 'GPX importieren',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||||
'places.urlResolved': 'Ort aus URL importiert',
|
'places.urlResolved': 'Ort aus URL importiert',
|
||||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||||
|
'places.importGoogleList': 'Google Liste',
|
||||||
|
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
||||||
|
'places.googleListImported': '{count} Orte aus "{list}" importiert',
|
||||||
|
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
|
||||||
|
'places.viewDetails': 'Details anzeigen',
|
||||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||||
'places.all': 'Alle',
|
'places.all': 'Alle',
|
||||||
'places.unplanned': 'Ungeplant',
|
'places.unplanned': 'Ungeplant',
|
||||||
@@ -757,6 +846,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Reservierung',
|
'inspector.addRes': 'Reservierung',
|
||||||
'inspector.editRes': 'Reservierung bearbeiten',
|
'inspector.editRes': 'Reservierung bearbeiten',
|
||||||
'inspector.participants': 'Teilnehmer',
|
'inspector.participants': 'Teilnehmer',
|
||||||
|
'inspector.trackStats': 'Streckendaten',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Buchungen',
|
'reservations.title': 'Buchungen',
|
||||||
@@ -839,6 +929,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'CSV exportieren',
|
||||||
'budget.emptyTitle': 'Noch kein Budget erstellt',
|
'budget.emptyTitle': 'Noch kein Budget erstellt',
|
||||||
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
|
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
|
||||||
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
|
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
|
||||||
@@ -853,6 +944,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Pro Tag',
|
'budget.table.perDay': 'Pro Tag',
|
||||||
'budget.table.perPersonDay': 'P. p / Tag',
|
'budget.table.perPersonDay': 'P. p / Tag',
|
||||||
'budget.table.note': 'Notiz',
|
'budget.table.note': 'Notiz',
|
||||||
|
'budget.table.date': 'Datum',
|
||||||
'budget.newEntry': 'Neuer Eintrag',
|
'budget.newEntry': 'Neuer Eintrag',
|
||||||
'budget.defaultEntry': 'Neuer Eintrag',
|
'budget.defaultEntry': 'Neuer Eintrag',
|
||||||
'budget.defaultCategory': 'Neue Kategorie',
|
'budget.defaultCategory': 'Neue Kategorie',
|
||||||
@@ -1246,12 +1338,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'Immich Server URL',
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
'memories.immichApiKey': 'API-Schlüssel',
|
'memories.immichApiKey': 'API-Schlüssel',
|
||||||
'memories.testConnection': 'Verbindung testen',
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
'memories.connected': 'Verbunden',
|
'memories.connected': 'Verbunden',
|
||||||
'memories.disconnected': 'Nicht verbunden',
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||||
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||||
'memories.saved': 'Immich-Einstellungen gespeichert',
|
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||||
'memories.addPhotos': 'Fotos hinzufügen',
|
'memories.addPhotos': 'Fotos hinzufügen',
|
||||||
|
'memories.linkAlbum': 'Album verknüpfen',
|
||||||
|
'memories.selectAlbum': 'Immich-Album auswählen',
|
||||||
|
'memories.noAlbums': 'Keine Alben gefunden',
|
||||||
|
'memories.syncAlbum': 'Album synchronisieren',
|
||||||
|
'memories.unlinkAlbum': 'Album trennen',
|
||||||
|
'memories.photos': 'Fotos',
|
||||||
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||||
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||||
'memories.selected': 'ausgewählt',
|
'memories.selected': 'ausgewählt',
|
||||||
@@ -1286,6 +1385,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Heute',
|
'collab.chat.today': 'Heute',
|
||||||
'collab.chat.yesterday': 'Gestern',
|
'collab.chat.yesterday': 'Gestern',
|
||||||
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
||||||
|
'collab.chat.reply': 'Antworten',
|
||||||
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||||
'collab.chat.justNow': 'gerade eben',
|
'collab.chat.justNow': 'gerade eben',
|
||||||
'collab.chat.minutesAgo': 'vor {n} Min.',
|
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||||
@@ -1336,6 +1436,55 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.polls.options': 'Optionen',
|
'collab.polls.options': 'Optionen',
|
||||||
'collab.polls.delete': 'Löschen',
|
'collab.polls.delete': 'Löschen',
|
||||||
'collab.polls.closedSection': 'Geschlossen',
|
'collab.polls.closedSection': 'Geschlossen',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Berechtigungen',
|
||||||
|
'perm.title': 'Berechtigungseinstellungen',
|
||||||
|
'perm.subtitle': 'Steuern Sie, wer Aktionen in der Anwendung ausführen kann',
|
||||||
|
'perm.saved': 'Berechtigungseinstellungen gespeichert',
|
||||||
|
'perm.resetDefaults': 'Auf Standard zurücksetzen',
|
||||||
|
'perm.customized': 'angepasst',
|
||||||
|
'perm.level.admin': 'Nur Administrator',
|
||||||
|
'perm.level.tripOwner': 'Reise-Eigentümer',
|
||||||
|
'perm.level.tripMember': 'Reise-Mitglieder',
|
||||||
|
'perm.level.everybody': 'Alle',
|
||||||
|
'perm.cat.trip': 'Reiseverwaltung',
|
||||||
|
'perm.cat.members': 'Mitgliederverwaltung',
|
||||||
|
'perm.cat.files': 'Dateien',
|
||||||
|
'perm.cat.content': 'Inhalte & Zeitplan',
|
||||||
|
'perm.cat.extras': 'Budget, Packlisten & Zusammenarbeit',
|
||||||
|
'perm.action.trip_create': 'Reisen erstellen',
|
||||||
|
'perm.action.trip_edit': 'Reisedetails bearbeiten',
|
||||||
|
'perm.action.trip_delete': 'Reisen löschen',
|
||||||
|
'perm.action.trip_archive': 'Reisen archivieren / dearchivieren',
|
||||||
|
'perm.action.trip_cover_upload': 'Titelbild hochladen',
|
||||||
|
'perm.action.member_manage': 'Mitglieder hinzufügen / entfernen',
|
||||||
|
'perm.action.file_upload': 'Dateien hochladen',
|
||||||
|
'perm.action.file_edit': 'Datei-Metadaten bearbeiten',
|
||||||
|
'perm.action.file_delete': 'Dateien löschen',
|
||||||
|
'perm.action.place_edit': 'Orte hinzufügen / bearbeiten / löschen',
|
||||||
|
'perm.action.day_edit': 'Tage, Notizen & Zuweisungen bearbeiten',
|
||||||
|
'perm.action.reservation_edit': 'Reservierungen verwalten',
|
||||||
|
'perm.action.budget_edit': 'Budget verwalten',
|
||||||
|
'perm.action.packing_edit': 'Packlisten verwalten',
|
||||||
|
'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)',
|
||||||
|
'perm.action.share_manage': 'Freigabelinks verwalten',
|
||||||
|
'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen',
|
||||||
|
'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern',
|
||||||
|
'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen',
|
||||||
|
'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Wer kann das Titelbild hochladen oder ändern',
|
||||||
|
'perm.actionHint.member_manage': 'Wer kann Reise-Mitglieder einladen oder entfernen',
|
||||||
|
'perm.actionHint.file_upload': 'Wer kann Dateien zu einer Reise hochladen',
|
||||||
|
'perm.actionHint.file_edit': 'Wer kann Dateibeschreibungen und Links bearbeiten',
|
||||||
|
'perm.actionHint.file_delete': 'Wer kann Dateien in den Papierkorb verschieben oder dauerhaft löschen',
|
||||||
|
'perm.actionHint.place_edit': 'Wer kann Orte hinzufügen, bearbeiten oder löschen',
|
||||||
|
'perm.actionHint.day_edit': 'Wer kann Tage, Tagesnotizen und Ort-Zuweisungen bearbeiten',
|
||||||
|
'perm.actionHint.reservation_edit': 'Wer kann Reservierungen erstellen, bearbeiten oder löschen',
|
||||||
|
'perm.actionHint.budget_edit': 'Wer kann Budgetposten erstellen, bearbeiten oder löschen',
|
||||||
|
'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten',
|
||||||
|
'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
|
||||||
|
'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default de
|
export default de
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.add': 'Add',
|
'common.add': 'Add',
|
||||||
'common.loading': 'Loading...',
|
'common.loading': 'Loading...',
|
||||||
|
'common.import': 'Import',
|
||||||
'common.error': 'Error',
|
'common.error': 'Error',
|
||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.all': 'All',
|
'common.all': 'All',
|
||||||
@@ -25,6 +26,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
'common.saving': 'Saving...',
|
'common.saving': 'Saving...',
|
||||||
|
'common.saved': 'Saved',
|
||||||
|
'trips.reminder': 'Reminder',
|
||||||
|
'trips.reminderNone': 'None',
|
||||||
|
'trips.reminderDay': 'day',
|
||||||
|
'trips.reminderDays': 'days',
|
||||||
|
'trips.reminderCustom': 'Custom',
|
||||||
|
'trips.reminderDaysBefore': 'days before departure',
|
||||||
|
'trips.reminderDisabledHint': 'Trip reminders are disabled. Enable them in Admin > Settings > Notifications.',
|
||||||
'common.update': 'Update',
|
'common.update': 'Update',
|
||||||
'common.change': 'Change',
|
'common.change': 'Change',
|
||||||
'common.uploading': 'Uploading…',
|
'common.uploading': 'Uploading…',
|
||||||
@@ -149,11 +158,28 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||||
'settings.notifyWebhook': 'Webhook notifications',
|
'settings.notifyWebhook': 'Webhook notifications',
|
||||||
|
'admin.notifications.title': 'Notifications',
|
||||||
|
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||||
|
'admin.notifications.none': 'Disabled',
|
||||||
|
'admin.notifications.email': 'Email (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Notification Events',
|
||||||
|
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||||
|
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||||
|
'admin.notifications.save': 'Save notification settings',
|
||||||
|
'admin.notifications.saved': 'Notification settings saved',
|
||||||
|
'admin.notifications.testWebhook': 'Send test webhook',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||||
'admin.smtp.title': 'Email & Notifications',
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
'admin.smtp.testButton': 'Send test email',
|
'admin.smtp.testButton': 'Send test email',
|
||||||
|
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||||
'admin.smtp.testFailed': 'Test email failed',
|
'admin.smtp.testFailed': 'Test email failed',
|
||||||
|
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||||
|
'settings.notificationsActive': 'Active channel',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||||
'share.linkTitle': 'Public Link',
|
'share.linkTitle': 'Public Link',
|
||||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||||
@@ -185,6 +211,31 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'share.permCollab': 'Chat',
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'On',
|
'settings.on': 'On',
|
||||||
'settings.off': 'Off',
|
'settings.off': 'Off',
|
||||||
|
'settings.mcp.title': 'MCP Configuration',
|
||||||
|
'settings.mcp.endpoint': 'MCP Endpoint',
|
||||||
|
'settings.mcp.clientConfig': 'Client Configuration',
|
||||||
|
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
|
||||||
|
'settings.mcp.copy': 'Copy',
|
||||||
|
'settings.mcp.copied': 'Copied!',
|
||||||
|
'settings.mcp.apiTokens': 'API Tokens',
|
||||||
|
'settings.mcp.createToken': 'Create New Token',
|
||||||
|
'settings.mcp.noTokens': 'No tokens yet. Create one to connect MCP clients.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Created',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Used',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Delete Token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'This token will stop working immediately. Any MCP client using it will lose access.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Create API Token',
|
||||||
|
'settings.mcp.modal.tokenName': 'Token Name',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'e.g. Claude Desktop, Work laptop',
|
||||||
|
'settings.mcp.modal.creating': 'Creating…',
|
||||||
|
'settings.mcp.modal.create': 'Create Token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token Created',
|
||||||
|
'settings.mcp.modal.createdWarning': 'This token will only be shown once. Copy and store it now — it cannot be recovered.',
|
||||||
|
'settings.mcp.modal.done': 'Done',
|
||||||
|
'settings.mcp.toast.created': 'Token created',
|
||||||
|
'settings.mcp.toast.createError': 'Failed to create token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token deleted',
|
||||||
|
'settings.mcp.toast.deleteError': 'Failed to delete token',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.username': 'Username',
|
'settings.username': 'Username',
|
||||||
'settings.email': 'Email',
|
'settings.email': 'Email',
|
||||||
@@ -200,8 +251,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Please enter current and new password',
|
'settings.passwordRequired': 'Please enter current and new password',
|
||||||
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||||
'settings.passwordMismatch': 'Passwords do not match',
|
'settings.passwordMismatch': 'Passwords do not match',
|
||||||
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
|
'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character',
|
||||||
'settings.passwordChanged': 'Password changed successfully',
|
'settings.passwordChanged': 'Password changed successfully',
|
||||||
|
'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
|
||||||
'settings.deleteAccount': 'Delete account',
|
'settings.deleteAccount': 'Delete account',
|
||||||
'settings.deleteAccountTitle': 'Delete your account?',
|
'settings.deleteAccountTitle': 'Delete your account?',
|
||||||
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
||||||
@@ -221,6 +273,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Upload failed',
|
'settings.avatarError': 'Upload failed',
|
||||||
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||||
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.',
|
||||||
|
'settings.mfa.backupTitle': 'Backup codes',
|
||||||
|
'settings.mfa.backupDescription': 'Use these one-time backup codes if you lose access to your authenticator app.',
|
||||||
|
'settings.mfa.backupWarning': 'Save these codes now. Each code can only be used once.',
|
||||||
|
'settings.mfa.backupCopy': 'Copy codes',
|
||||||
|
'settings.mfa.backupDownload': 'Download TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Print / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Backup codes copied',
|
||||||
'settings.mfa.enabled': '2FA is enabled on your account.',
|
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||||
'settings.mfa.disabled': '2FA is not enabled.',
|
'settings.mfa.disabled': '2FA is not enabled.',
|
||||||
'settings.mfa.setup': 'Set up authenticator',
|
'settings.mfa.setup': 'Set up authenticator',
|
||||||
@@ -263,6 +323,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Sign In',
|
'login.signIn': 'Sign In',
|
||||||
'login.createAdmin': 'Create Admin Account',
|
'login.createAdmin': 'Create Admin Account',
|
||||||
'login.createAdminHint': 'Set up the first admin account for TREK.',
|
'login.createAdminHint': 'Set up the first admin account for TREK.',
|
||||||
|
'login.setNewPassword': 'Set New Password',
|
||||||
|
'login.setNewPasswordHint': 'You must change your password before continuing.',
|
||||||
'login.createAccount': 'Create Account',
|
'login.createAccount': 'Create Account',
|
||||||
'login.createAccountHint': 'Register a new account.',
|
'login.createAccountHint': 'Register a new account.',
|
||||||
'login.creating': 'Creating…',
|
'login.creating': 'Creating…',
|
||||||
@@ -289,7 +351,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwords do not match',
|
'register.passwordMismatch': 'Passwords do not match',
|
||||||
'register.passwordTooShort': 'Password must be at least 6 characters',
|
'register.passwordTooShort': 'Password must be at least 8 characters',
|
||||||
'register.failed': 'Registration failed',
|
'register.failed': 'Registration failed',
|
||||||
'register.getStarted': 'Get Started',
|
'register.getStarted': 'Get Started',
|
||||||
'register.subtitle': 'Create an account and start planning your dream trips.',
|
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||||
@@ -365,6 +427,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Settings',
|
'admin.tabs.settings': 'Settings',
|
||||||
'admin.allowRegistration': 'Allow Registration',
|
'admin.allowRegistration': 'Allow Registration',
|
||||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||||
|
'admin.requireMfa': 'Require two-factor authentication (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
|
||||||
'admin.apiKeys': 'API Keys',
|
'admin.apiKeys': 'API Keys',
|
||||||
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
@@ -433,14 +497,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||||
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol for AI assistant integration',
|
||||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||||
'admin.addons.subtitleAfter': ' experience.',
|
'admin.addons.subtitleAfter': ' experience.',
|
||||||
'admin.addons.enabled': 'Enabled',
|
'admin.addons.enabled': 'Enabled',
|
||||||
'admin.addons.disabled': 'Disabled',
|
'admin.addons.disabled': 'Disabled',
|
||||||
'admin.addons.type.trip': 'Trip',
|
'admin.addons.type.trip': 'Trip',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Integration',
|
||||||
'admin.addons.tripHint': 'Available as a tab within each trip',
|
'admin.addons.tripHint': 'Available as a tab within each trip',
|
||||||
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
||||||
|
'admin.addons.integrationHint': 'Backend services and API integrations with no dedicated page',
|
||||||
'admin.addons.toast.updated': 'Addon updated',
|
'admin.addons.toast.updated': 'Addon updated',
|
||||||
'admin.addons.toast.error': 'Failed to update addon',
|
'admin.addons.toast.error': 'Failed to update addon',
|
||||||
'admin.addons.noAddons': 'No addons available',
|
'admin.addons.noAddons': 'No addons available',
|
||||||
@@ -457,6 +525,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
|
'admin.tabs.mcpTokens': 'MCP Tokens',
|
||||||
|
'admin.mcpTokens.title': 'MCP Tokens',
|
||||||
|
'admin.mcpTokens.subtitle': 'Manage API tokens across all users',
|
||||||
|
'admin.mcpTokens.owner': 'Owner',
|
||||||
|
'admin.mcpTokens.tokenName': 'Token Name',
|
||||||
|
'admin.mcpTokens.created': 'Created',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Last Used',
|
||||||
|
'admin.mcpTokens.never': 'Never',
|
||||||
|
'admin.mcpTokens.empty': 'No MCP tokens have been created yet',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Delete Token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'This will revoke the token immediately. The user will lose MCP access through this token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token deleted',
|
||||||
|
'admin.mcpTokens.deleteError': 'Failed to delete token',
|
||||||
|
'admin.mcpTokens.loadError': 'Failed to load tokens',
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||||
@@ -600,6 +682,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||||
'atlas.addToBucket': 'Add to bucket list',
|
'atlas.addToBucket': 'Add to bucket list',
|
||||||
'atlas.addPoi': 'Add place',
|
'atlas.addPoi': 'Add place',
|
||||||
|
'atlas.searchCountry': 'Search a country...',
|
||||||
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||||
'atlas.month': 'Month',
|
'atlas.month': 'Month',
|
||||||
'atlas.year': 'Year',
|
'atlas.year': 'Year',
|
||||||
@@ -608,7 +691,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Stats',
|
'atlas.statsTab': 'Stats',
|
||||||
'atlas.bucketTab': 'Bucket List',
|
'atlas.bucketTab': 'Bucket List',
|
||||||
'atlas.addBucket': 'Add to bucket list',
|
'atlas.addBucket': 'Add to bucket list',
|
||||||
'atlas.bucketNamePlaceholder': 'Place or destination...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
||||||
'atlas.bucketEmpty': 'Your bucket list is empty',
|
'atlas.bucketEmpty': 'Your bucket list is empty',
|
||||||
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
||||||
@@ -621,7 +703,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.nextTrip': 'Next trip',
|
'atlas.nextTrip': 'Next trip',
|
||||||
'atlas.daysLeft': 'days left',
|
'atlas.daysLeft': 'days left',
|
||||||
'atlas.streak': 'Streak',
|
'atlas.streak': 'Streak',
|
||||||
'atlas.year': 'year',
|
|
||||||
'atlas.years': 'years',
|
'atlas.years': 'years',
|
||||||
'atlas.yearInRow': 'year in a row',
|
'atlas.yearInRow': 'year in a row',
|
||||||
'atlas.yearsInRow': 'years in a row',
|
'atlas.yearsInRow': 'years in a row',
|
||||||
@@ -651,6 +732,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Files',
|
'trip.tabs.files': 'Files',
|
||||||
'trip.loading': 'Loading trip...',
|
'trip.loading': 'Loading trip...',
|
||||||
|
'trip.loadingPhotos': 'Loading place photos...',
|
||||||
'trip.mobilePlan': 'Plan',
|
'trip.mobilePlan': 'Plan',
|
||||||
'trip.mobilePlaces': 'Places',
|
'trip.mobilePlaces': 'Places',
|
||||||
'trip.toast.placeUpdated': 'Place updated',
|
'trip.toast.placeUpdated': 'Place updated',
|
||||||
@@ -697,10 +779,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Add Place/Activity',
|
'places.addPlace': 'Add Place/Activity',
|
||||||
'places.importGpx': 'Import GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} places imported from GPX',
|
'places.gpxImported': '{count} places imported from GPX',
|
||||||
'places.urlResolved': 'Place imported from URL',
|
'places.urlResolved': 'Place imported from URL',
|
||||||
'places.gpxError': 'GPX import failed',
|
'places.gpxError': 'GPX import failed',
|
||||||
|
'places.importGoogleList': 'Google List',
|
||||||
|
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
|
||||||
|
'places.googleListImported': '{count} places imported from "{list}"',
|
||||||
|
'places.googleListError': 'Failed to import Google Maps list',
|
||||||
|
'places.viewDetails': 'View Details',
|
||||||
'places.assignToDay': 'Add to which day?',
|
'places.assignToDay': 'Add to which day?',
|
||||||
'places.all': 'All',
|
'places.all': 'All',
|
||||||
'places.unplanned': 'Unplanned',
|
'places.unplanned': 'Unplanned',
|
||||||
@@ -756,6 +843,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Reservation',
|
'inspector.addRes': 'Reservation',
|
||||||
'inspector.editRes': 'Edit Reservation',
|
'inspector.editRes': 'Edit Reservation',
|
||||||
'inspector.participants': 'Participants',
|
'inspector.participants': 'Participants',
|
||||||
|
'inspector.trackStats': 'Track Stats',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Bookings',
|
'reservations.title': 'Bookings',
|
||||||
@@ -838,6 +926,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'Export CSV',
|
||||||
'budget.emptyTitle': 'No budget created yet',
|
'budget.emptyTitle': 'No budget created yet',
|
||||||
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
||||||
'budget.emptyPlaceholder': 'Enter category name...',
|
'budget.emptyPlaceholder': 'Enter category name...',
|
||||||
@@ -852,6 +941,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Per Day',
|
'budget.table.perDay': 'Per Day',
|
||||||
'budget.table.perPersonDay': 'P. p / Day',
|
'budget.table.perPersonDay': 'P. p / Day',
|
||||||
'budget.table.note': 'Note',
|
'budget.table.note': 'Note',
|
||||||
|
'budget.table.date': 'Date',
|
||||||
'budget.newEntry': 'New Entry',
|
'budget.newEntry': 'New Entry',
|
||||||
'budget.defaultEntry': 'New Entry',
|
'budget.defaultEntry': 'New Entry',
|
||||||
'budget.defaultCategory': 'New Category',
|
'budget.defaultCategory': 'New Category',
|
||||||
@@ -1245,12 +1335,19 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'Immich Server URL',
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
'memories.immichApiKey': 'API Key',
|
'memories.immichApiKey': 'API Key',
|
||||||
'memories.testConnection': 'Test connection',
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.testFirst': 'Test connection first',
|
||||||
'memories.connected': 'Connected',
|
'memories.connected': 'Connected',
|
||||||
'memories.disconnected': 'Not connected',
|
'memories.disconnected': 'Not connected',
|
||||||
'memories.connectionSuccess': 'Connected to Immich',
|
'memories.connectionSuccess': 'Connected to Immich',
|
||||||
'memories.connectionError': 'Could not connect to Immich',
|
'memories.connectionError': 'Could not connect to Immich',
|
||||||
'memories.saved': 'Immich settings saved',
|
'memories.saved': 'Immich settings saved',
|
||||||
'memories.addPhotos': 'Add photos',
|
'memories.addPhotos': 'Add photos',
|
||||||
|
'memories.linkAlbum': 'Link Album',
|
||||||
|
'memories.selectAlbum': 'Select Immich Album',
|
||||||
|
'memories.noAlbums': 'No albums found',
|
||||||
|
'memories.syncAlbum': 'Sync album',
|
||||||
|
'memories.unlinkAlbum': 'Unlink album',
|
||||||
|
'memories.photos': 'photos',
|
||||||
'memories.selectPhotos': 'Select photos from Immich',
|
'memories.selectPhotos': 'Select photos from Immich',
|
||||||
'memories.selectHint': 'Tap photos to select them.',
|
'memories.selectHint': 'Tap photos to select them.',
|
||||||
'memories.selected': 'selected',
|
'memories.selected': 'selected',
|
||||||
@@ -1285,6 +1382,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Today',
|
'collab.chat.today': 'Today',
|
||||||
'collab.chat.yesterday': 'Yesterday',
|
'collab.chat.yesterday': 'Yesterday',
|
||||||
'collab.chat.deletedMessage': 'deleted a message',
|
'collab.chat.deletedMessage': 'deleted a message',
|
||||||
|
'collab.chat.reply': 'Reply',
|
||||||
'collab.chat.loadMore': 'Load older messages',
|
'collab.chat.loadMore': 'Load older messages',
|
||||||
'collab.chat.justNow': 'just now',
|
'collab.chat.justNow': 'just now',
|
||||||
'collab.chat.minutesAgo': '{n}m ago',
|
'collab.chat.minutesAgo': '{n}m ago',
|
||||||
@@ -1335,6 +1433,55 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.polls.options': 'Options',
|
'collab.polls.options': 'Options',
|
||||||
'collab.polls.delete': 'Delete',
|
'collab.polls.delete': 'Delete',
|
||||||
'collab.polls.closedSection': 'Closed',
|
'collab.polls.closedSection': 'Closed',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permissions',
|
||||||
|
'perm.title': 'Permission Settings',
|
||||||
|
'perm.subtitle': 'Control who can perform actions across the application',
|
||||||
|
'perm.saved': 'Permission settings saved',
|
||||||
|
'perm.resetDefaults': 'Reset to defaults',
|
||||||
|
'perm.customized': 'customized',
|
||||||
|
'perm.level.admin': 'Admin only',
|
||||||
|
'perm.level.tripOwner': 'Trip owner',
|
||||||
|
'perm.level.tripMember': 'Trip members',
|
||||||
|
'perm.level.everybody': 'Everyone',
|
||||||
|
'perm.cat.trip': 'Trip Management',
|
||||||
|
'perm.cat.members': 'Member Management',
|
||||||
|
'perm.cat.files': 'Files',
|
||||||
|
'perm.cat.content': 'Content & Schedule',
|
||||||
|
'perm.cat.extras': 'Budget, Packing & Collaboration',
|
||||||
|
'perm.action.trip_create': 'Create trips',
|
||||||
|
'perm.action.trip_edit': 'Edit trip details',
|
||||||
|
'perm.action.trip_delete': 'Delete trips',
|
||||||
|
'perm.action.trip_archive': 'Archive / unarchive trips',
|
||||||
|
'perm.action.trip_cover_upload': 'Upload cover image',
|
||||||
|
'perm.action.member_manage': 'Add / remove members',
|
||||||
|
'perm.action.file_upload': 'Upload files',
|
||||||
|
'perm.action.file_edit': 'Edit file metadata',
|
||||||
|
'perm.action.file_delete': 'Delete files',
|
||||||
|
'perm.action.place_edit': 'Add / edit / delete places',
|
||||||
|
'perm.action.day_edit': 'Edit days, notes & assignments',
|
||||||
|
'perm.action.reservation_edit': 'Manage reservations',
|
||||||
|
'perm.action.budget_edit': 'Manage budget',
|
||||||
|
'perm.action.packing_edit': 'Manage packing lists',
|
||||||
|
'perm.action.collab_edit': 'Collaboration (notes, polls, chat)',
|
||||||
|
'perm.action.share_manage': 'Manage share links',
|
||||||
|
'perm.actionHint.trip_create': 'Who can create new trips',
|
||||||
|
'perm.actionHint.trip_edit': 'Who can change trip name, dates, description and currency',
|
||||||
|
'perm.actionHint.trip_delete': 'Who can permanently delete a trip',
|
||||||
|
'perm.actionHint.trip_archive': 'Who can archive or unarchive a trip',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Who can upload or change the cover image',
|
||||||
|
'perm.actionHint.member_manage': 'Who can invite or remove trip members',
|
||||||
|
'perm.actionHint.file_upload': 'Who can upload files to a trip',
|
||||||
|
'perm.actionHint.file_edit': 'Who can edit file descriptions and links',
|
||||||
|
'perm.actionHint.file_delete': 'Who can move files to trash or permanently delete them',
|
||||||
|
'perm.actionHint.place_edit': 'Who can add, edit or delete places',
|
||||||
|
'perm.actionHint.day_edit': 'Who can edit days, day notes and place assignments',
|
||||||
|
'perm.actionHint.reservation_edit': 'Who can create, edit or delete reservations',
|
||||||
|
'perm.actionHint.budget_edit': 'Who can create, edit or delete budget items',
|
||||||
|
'perm.actionHint.packing_edit': 'Who can manage packing items and bags',
|
||||||
|
'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages',
|
||||||
|
'perm.actionHint.share_manage': 'Who can create or delete public share links',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default en
|
export default en
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const es: Record<string, string> = {
|
|||||||
'common.edit': 'Editar',
|
'common.edit': 'Editar',
|
||||||
'common.add': 'Añadir',
|
'common.add': 'Añadir',
|
||||||
'common.loading': 'Cargando...',
|
'common.loading': 'Cargando...',
|
||||||
|
'common.import': 'Importar',
|
||||||
'common.error': 'Error',
|
'common.error': 'Error',
|
||||||
'common.back': 'Atrás',
|
'common.back': 'Atrás',
|
||||||
'common.all': 'Todo',
|
'common.all': 'Todo',
|
||||||
@@ -25,6 +26,14 @@ const es: Record<string, string> = {
|
|||||||
'common.email': 'Correo',
|
'common.email': 'Correo',
|
||||||
'common.password': 'Contraseña',
|
'common.password': 'Contraseña',
|
||||||
'common.saving': 'Guardando...',
|
'common.saving': 'Guardando...',
|
||||||
|
'common.saved': 'Guardado',
|
||||||
|
'trips.reminder': 'Recordatorio',
|
||||||
|
'trips.reminderNone': 'Ninguno',
|
||||||
|
'trips.reminderDay': 'día',
|
||||||
|
'trips.reminderDays': 'días',
|
||||||
|
'trips.reminderCustom': 'Personalizado',
|
||||||
|
'trips.reminderDaysBefore': 'días antes de la salida',
|
||||||
|
'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
|
||||||
'common.update': 'Actualizar',
|
'common.update': 'Actualizar',
|
||||||
'common.change': 'Cambiar',
|
'common.change': 'Cambiar',
|
||||||
'common.uploading': 'Subiendo…',
|
'common.uploading': 'Subiendo…',
|
||||||
@@ -150,9 +159,26 @@ const es: Record<string, string> = {
|
|||||||
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
|
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
|
||||||
'settings.notifyWebhook': 'Notificaciones webhook',
|
'settings.notifyWebhook': 'Notificaciones webhook',
|
||||||
|
'settings.notificationsDisabled': 'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.',
|
||||||
|
'settings.notificationsActive': 'Canal activo',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Los eventos de notificación son configurados por el administrador.',
|
||||||
|
'admin.notifications.title': 'Notificaciones',
|
||||||
|
'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
|
||||||
|
'admin.notifications.none': 'Desactivado',
|
||||||
|
'admin.notifications.email': 'Correo (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Eventos de notificación',
|
||||||
|
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
|
||||||
|
'admin.notifications.configureFirst': 'Configura primero los ajustes SMTP o webhook a continuación, luego activa los eventos.',
|
||||||
|
'admin.notifications.save': 'Guardar configuración de notificaciones',
|
||||||
|
'admin.notifications.saved': 'Configuración de notificaciones guardada',
|
||||||
|
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Webhook de prueba enviado correctamente',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Error al enviar webhook de prueba',
|
||||||
'admin.smtp.title': 'Correo y notificaciones',
|
'admin.smtp.title': 'Correo y notificaciones',
|
||||||
'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
|
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
|
||||||
'admin.smtp.testButton': 'Enviar correo de prueba',
|
'admin.smtp.testButton': 'Enviar correo de prueba',
|
||||||
|
'admin.webhook.hint': 'Enviar notificaciones a un webhook externo (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
||||||
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
||||||
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
||||||
@@ -186,6 +212,31 @@ const es: Record<string, string> = {
|
|||||||
'share.permCollab': 'Chat',
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'Activado',
|
'settings.on': 'Activado',
|
||||||
'settings.off': 'Desactivado',
|
'settings.off': 'Desactivado',
|
||||||
|
'settings.mcp.title': 'Configuración MCP',
|
||||||
|
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||||
|
'settings.mcp.clientConfig': 'Configuración del cliente',
|
||||||
|
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
|
||||||
|
'settings.mcp.copy': 'Copiar',
|
||||||
|
'settings.mcp.copied': '¡Copiado!',
|
||||||
|
'settings.mcp.apiTokens': 'Tokens de API',
|
||||||
|
'settings.mcp.createToken': 'Crear nuevo token',
|
||||||
|
'settings.mcp.noTokens': 'Sin tokens aún. Crea uno para conectar clientes MCP.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Creado',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Usado',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Eliminar token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Este token dejará de funcionar de inmediato. Cualquier cliente MCP que lo use perderá el acceso.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Crear token de API',
|
||||||
|
'settings.mcp.modal.tokenName': 'Nombre del token',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'p. ej. Claude Desktop, Portátil de trabajo',
|
||||||
|
'settings.mcp.modal.creating': 'Creando…',
|
||||||
|
'settings.mcp.modal.create': 'Crear token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token creado',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Este token solo se mostrará una vez. Cópialo y guárdalo ahora — no se podrá recuperar.',
|
||||||
|
'settings.mcp.modal.done': 'Listo',
|
||||||
|
'settings.mcp.toast.created': 'Token creado',
|
||||||
|
'settings.mcp.toast.createError': 'Error al crear el token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token eliminado',
|
||||||
|
'settings.mcp.toast.deleteError': 'Error al eliminar el token',
|
||||||
'settings.account': 'Cuenta',
|
'settings.account': 'Cuenta',
|
||||||
'settings.username': 'Usuario',
|
'settings.username': 'Usuario',
|
||||||
'settings.email': 'Correo',
|
'settings.email': 'Correo',
|
||||||
@@ -193,6 +244,7 @@ const es: Record<string, string> = {
|
|||||||
'settings.roleAdmin': 'Administrador',
|
'settings.roleAdmin': 'Administrador',
|
||||||
'settings.oidcLinked': 'Vinculado con',
|
'settings.oidcLinked': 'Vinculado con',
|
||||||
'settings.changePassword': 'Cambiar contraseña',
|
'settings.changePassword': 'Cambiar contraseña',
|
||||||
|
'settings.mustChangePassword': 'Debe cambiar su contraseña antes de continuar. Establezca una nueva contraseña a continuación.',
|
||||||
'settings.currentPassword': 'Contraseña actual',
|
'settings.currentPassword': 'Contraseña actual',
|
||||||
'settings.newPassword': 'Nueva contraseña',
|
'settings.newPassword': 'Nueva contraseña',
|
||||||
'settings.confirmPassword': 'Confirmar nueva contraseña',
|
'settings.confirmPassword': 'Confirmar nueva contraseña',
|
||||||
@@ -211,6 +263,14 @@ const es: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Guardar perfil',
|
'settings.saveProfile': 'Guardar perfil',
|
||||||
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
||||||
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.',
|
||||||
|
'settings.mfa.backupTitle': 'Códigos de respaldo',
|
||||||
|
'settings.mfa.backupDescription': 'Usa estos códigos de un solo uso si pierdes acceso a tu app autenticadora.',
|
||||||
|
'settings.mfa.backupWarning': 'Guárdalos ahora. Cada código solo se puede usar una vez.',
|
||||||
|
'settings.mfa.backupCopy': 'Copiar códigos',
|
||||||
|
'settings.mfa.backupDownload': 'Descargar TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Imprimir / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Códigos de respaldo copiados',
|
||||||
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
||||||
'settings.mfa.disabled': '2FA no está activado.',
|
'settings.mfa.disabled': '2FA no está activado.',
|
||||||
'settings.mfa.setup': 'Configurar autenticador',
|
'settings.mfa.setup': 'Configurar autenticador',
|
||||||
@@ -262,6 +322,8 @@ const es: Record<string, string> = {
|
|||||||
'login.signIn': 'Entrar',
|
'login.signIn': 'Entrar',
|
||||||
'login.createAdmin': 'Crear cuenta de administrador',
|
'login.createAdmin': 'Crear cuenta de administrador',
|
||||||
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
|
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
|
||||||
|
'login.setNewPassword': 'Establecer nueva contraseña',
|
||||||
|
'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.',
|
||||||
'login.createAccount': 'Crear cuenta',
|
'login.createAccount': 'Crear cuenta',
|
||||||
'login.createAccountHint': 'Crea una cuenta nueva.',
|
'login.createAccountHint': 'Crea una cuenta nueva.',
|
||||||
'login.creating': 'Creando…',
|
'login.creating': 'Creando…',
|
||||||
@@ -287,7 +349,7 @@ const es: Record<string, string> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Las contraseñas no coinciden',
|
'register.passwordMismatch': 'Las contraseñas no coinciden',
|
||||||
'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres',
|
'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
|
||||||
'register.failed': 'Falló el registro',
|
'register.failed': 'Falló el registro',
|
||||||
'register.getStarted': 'Empezar',
|
'register.getStarted': 'Empezar',
|
||||||
'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
|
'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
|
||||||
@@ -363,6 +425,8 @@ const es: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Ajustes',
|
'admin.tabs.settings': 'Ajustes',
|
||||||
'admin.allowRegistration': 'Permitir el registro',
|
'admin.allowRegistration': 'Permitir el registro',
|
||||||
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
||||||
|
'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
|
||||||
'admin.apiKeys': 'Claves API',
|
'admin.apiKeys': 'Claves API',
|
||||||
'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
||||||
'admin.mapsKey': 'Clave API de Google Maps',
|
'admin.mapsKey': 'Clave API de Google Maps',
|
||||||
@@ -420,8 +484,10 @@ const es: Record<string, string> = {
|
|||||||
'admin.addons.disabled': 'Desactivado',
|
'admin.addons.disabled': 'Desactivado',
|
||||||
'admin.addons.type.trip': 'Viaje',
|
'admin.addons.type.trip': 'Viaje',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Integración',
|
||||||
'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
|
'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
|
||||||
'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal',
|
'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal',
|
||||||
|
'admin.addons.integrationHint': 'Servicios backend e integraciones de API sin página dedicada',
|
||||||
'admin.addons.toast.updated': 'Complemento actualizado',
|
'admin.addons.toast.updated': 'Complemento actualizado',
|
||||||
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
|
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
|
||||||
'admin.addons.noAddons': 'No hay complementos disponibles',
|
'admin.addons.noAddons': 'No hay complementos disponibles',
|
||||||
@@ -436,6 +502,22 @@ const es: Record<string, string> = {
|
|||||||
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
|
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
|
||||||
'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
|
'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.title': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios',
|
||||||
|
'admin.mcpTokens.owner': 'Propietario',
|
||||||
|
'admin.mcpTokens.tokenName': 'Nombre del token',
|
||||||
|
'admin.mcpTokens.created': 'Creado',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Último uso',
|
||||||
|
'admin.mcpTokens.never': 'Nunca',
|
||||||
|
'admin.mcpTokens.empty': 'Aún no se han creado tokens MCP',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Eliminar token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Este token se revocará inmediatamente. El usuario perderá el acceso MCP a través de este token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token eliminado',
|
||||||
|
'admin.mcpTokens.deleteError': 'No se pudo eliminar el token',
|
||||||
|
'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
@@ -615,9 +697,8 @@ const es: Record<string, string> = {
|
|||||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||||
'atlas.addPoi': 'Añadir lugar',
|
'atlas.addPoi': 'Añadir lugar',
|
||||||
'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)',
|
'atlas.searchCountry': 'Buscar un país...',
|
||||||
'atlas.month': 'Mes',
|
'atlas.month': 'Mes',
|
||||||
'atlas.year': 'Año',
|
|
||||||
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
||||||
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
|
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
|
||||||
|
|
||||||
@@ -630,6 +711,7 @@ const es: Record<string, string> = {
|
|||||||
'trip.tabs.budget': 'Presupuesto',
|
'trip.tabs.budget': 'Presupuesto',
|
||||||
'trip.tabs.files': 'Archivos',
|
'trip.tabs.files': 'Archivos',
|
||||||
'trip.loading': 'Cargando viaje...',
|
'trip.loading': 'Cargando viaje...',
|
||||||
|
'trip.loadingPhotos': 'Cargando fotos de los lugares...',
|
||||||
'trip.mobilePlan': 'Plan',
|
'trip.mobilePlan': 'Plan',
|
||||||
'trip.mobilePlaces': 'Lugares',
|
'trip.mobilePlaces': 'Lugares',
|
||||||
'trip.toast.placeUpdated': 'Lugar actualizado',
|
'trip.toast.placeUpdated': 'Lugar actualizado',
|
||||||
@@ -676,9 +758,14 @@ const es: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Añadir lugar/actividad',
|
'places.addPlace': 'Añadir lugar/actividad',
|
||||||
'places.importGpx': 'Importar GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||||
'places.gpxError': 'Error al importar GPX',
|
'places.gpxError': 'Error al importar GPX',
|
||||||
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
|
||||||
|
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||||
|
'places.googleListError': 'Error al importar la lista de Google Maps',
|
||||||
|
'places.viewDetails': 'Ver detalles',
|
||||||
'places.urlResolved': 'Lugar importado desde URL',
|
'places.urlResolved': 'Lugar importado desde URL',
|
||||||
'places.assignToDay': '¿A qué día añadirlo?',
|
'places.assignToDay': '¿A qué día añadirlo?',
|
||||||
'places.all': 'Todo',
|
'places.all': 'Todo',
|
||||||
@@ -736,6 +823,7 @@ const es: Record<string, string> = {
|
|||||||
'inspector.addRes': 'Reserva',
|
'inspector.addRes': 'Reserva',
|
||||||
'inspector.editRes': 'Editar reserva',
|
'inspector.editRes': 'Editar reserva',
|
||||||
'inspector.participants': 'Participantes',
|
'inspector.participants': 'Participantes',
|
||||||
|
'inspector.trackStats': 'Datos de la ruta',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Reservas',
|
'reservations.title': 'Reservas',
|
||||||
@@ -801,6 +889,7 @@ const es: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Presupuesto',
|
'budget.title': 'Presupuesto',
|
||||||
|
'budget.exportCsv': 'Exportar CSV',
|
||||||
'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
|
'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
|
||||||
'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje',
|
'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje',
|
||||||
'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
|
'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
|
||||||
@@ -815,6 +904,7 @@ const es: Record<string, string> = {
|
|||||||
'budget.table.perDay': 'Por día',
|
'budget.table.perDay': 'Por día',
|
||||||
'budget.table.perPersonDay': 'Por pers. / día',
|
'budget.table.perPersonDay': 'Por pers. / día',
|
||||||
'budget.table.note': 'Nota',
|
'budget.table.note': 'Nota',
|
||||||
|
'budget.table.date': 'Fecha',
|
||||||
'budget.newEntry': 'Nueva entrada',
|
'budget.newEntry': 'Nueva entrada',
|
||||||
'budget.defaultEntry': 'Nueva entrada',
|
'budget.defaultEntry': 'Nueva entrada',
|
||||||
'budget.defaultCategory': 'Nueva categoría',
|
'budget.defaultCategory': 'Nueva categoría',
|
||||||
@@ -1062,8 +1152,10 @@ const es: Record<string, string> = {
|
|||||||
'photos.linkPlace': 'Vincular lugar',
|
'photos.linkPlace': 'Vincular lugar',
|
||||||
'photos.noPlace': 'Sin lugar',
|
'photos.noPlace': 'Sin lugar',
|
||||||
'photos.uploadN': 'Subida de {n} foto(s)',
|
'photos.uploadN': 'Subida de {n} foto(s)',
|
||||||
'admin.addons.catalog.memories.name': 'Recuerdos',
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje',
|
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
|
||||||
'admin.addons.catalog.packing.name': 'Equipaje',
|
'admin.addons.catalog.packing.name': 'Equipaje',
|
||||||
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
|
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
|
||||||
'admin.addons.catalog.budget.name': 'Presupuesto',
|
'admin.addons.catalog.budget.name': 'Presupuesto',
|
||||||
@@ -1199,6 +1291,7 @@ const es: Record<string, string> = {
|
|||||||
'memories.immichUrl': 'URL del servidor Immich',
|
'memories.immichUrl': 'URL del servidor Immich',
|
||||||
'memories.immichApiKey': 'Clave API',
|
'memories.immichApiKey': 'Clave API',
|
||||||
'memories.testConnection': 'Probar conexión',
|
'memories.testConnection': 'Probar conexión',
|
||||||
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'No conectado',
|
'memories.disconnected': 'No conectado',
|
||||||
'memories.connectionSuccess': 'Conectado a Immich',
|
'memories.connectionSuccess': 'Conectado a Immich',
|
||||||
@@ -1208,6 +1301,12 @@ const es: Record<string, string> = {
|
|||||||
'memories.newest': 'Más recientes',
|
'memories.newest': 'Más recientes',
|
||||||
'memories.allLocations': 'Todas las ubicaciones',
|
'memories.allLocations': 'Todas las ubicaciones',
|
||||||
'memories.addPhotos': 'Añadir fotos',
|
'memories.addPhotos': 'Añadir fotos',
|
||||||
|
'memories.linkAlbum': 'Vincular álbum',
|
||||||
|
'memories.selectAlbum': 'Seleccionar álbum de Immich',
|
||||||
|
'memories.noAlbums': 'No se encontraron álbumes',
|
||||||
|
'memories.syncAlbum': 'Sincronizar álbum',
|
||||||
|
'memories.unlinkAlbum': 'Desvincular',
|
||||||
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
||||||
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
||||||
'memories.selected': 'seleccionado(s)',
|
'memories.selected': 'seleccionado(s)',
|
||||||
@@ -1239,6 +1338,7 @@ const es: Record<string, string> = {
|
|||||||
'collab.chat.today': 'Hoy',
|
'collab.chat.today': 'Hoy',
|
||||||
'collab.chat.yesterday': 'Ayer',
|
'collab.chat.yesterday': 'Ayer',
|
||||||
'collab.chat.deletedMessage': 'eliminó un mensaje',
|
'collab.chat.deletedMessage': 'eliminó un mensaje',
|
||||||
|
'collab.chat.reply': 'Responder',
|
||||||
'collab.chat.loadMore': 'Cargar mensajes anteriores',
|
'collab.chat.loadMore': 'Cargar mensajes anteriores',
|
||||||
'collab.chat.justNow': 'justo ahora',
|
'collab.chat.justNow': 'justo ahora',
|
||||||
'collab.chat.minutesAgo': 'hace {n} min',
|
'collab.chat.minutesAgo': 'hace {n} min',
|
||||||
@@ -1340,7 +1440,56 @@ const es: Record<string, string> = {
|
|||||||
|
|
||||||
// Settings (2.6.2)
|
// Settings (2.6.2)
|
||||||
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
||||||
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números',
|
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permisos',
|
||||||
|
'perm.title': 'Configuración de permisos',
|
||||||
|
'perm.subtitle': 'Controla quién puede realizar acciones en la aplicación',
|
||||||
|
'perm.saved': 'Configuración de permisos guardada',
|
||||||
|
'perm.resetDefaults': 'Restablecer valores predeterminados',
|
||||||
|
'perm.customized': 'personalizado',
|
||||||
|
'perm.level.admin': 'Solo administrador',
|
||||||
|
'perm.level.tripOwner': 'Propietario del viaje',
|
||||||
|
'perm.level.tripMember': 'Miembros del viaje',
|
||||||
|
'perm.level.everybody': 'Todos',
|
||||||
|
'perm.cat.trip': 'Gestión de viajes',
|
||||||
|
'perm.cat.members': 'Gestión de miembros',
|
||||||
|
'perm.cat.files': 'Archivos',
|
||||||
|
'perm.cat.content': 'Contenido y horario',
|
||||||
|
'perm.cat.extras': 'Presupuesto, equipaje y colaboración',
|
||||||
|
'perm.action.trip_create': 'Crear viajes',
|
||||||
|
'perm.action.trip_edit': 'Editar detalles del viaje',
|
||||||
|
'perm.action.trip_delete': 'Eliminar viajes',
|
||||||
|
'perm.action.trip_archive': 'Archivar / desarchivar viajes',
|
||||||
|
'perm.action.trip_cover_upload': 'Subir imagen de portada',
|
||||||
|
'perm.action.member_manage': 'Añadir / eliminar miembros',
|
||||||
|
'perm.action.file_upload': 'Subir archivos',
|
||||||
|
'perm.action.file_edit': 'Editar metadatos del archivo',
|
||||||
|
'perm.action.file_delete': 'Eliminar archivos',
|
||||||
|
'perm.action.place_edit': 'Añadir / editar / eliminar lugares',
|
||||||
|
'perm.action.day_edit': 'Editar días, notas y asignaciones',
|
||||||
|
'perm.action.reservation_edit': 'Gestionar reservas',
|
||||||
|
'perm.action.budget_edit': 'Gestionar presupuesto',
|
||||||
|
'perm.action.packing_edit': 'Gestionar listas de equipaje',
|
||||||
|
'perm.action.collab_edit': 'Colaboración (notas, encuestas, chat)',
|
||||||
|
'perm.action.share_manage': 'Gestionar enlaces compartidos',
|
||||||
|
'perm.actionHint.trip_create': 'Quién puede crear nuevos viajes',
|
||||||
|
'perm.actionHint.trip_edit': 'Quién puede cambiar el nombre, fechas, descripción y moneda del viaje',
|
||||||
|
'perm.actionHint.trip_delete': 'Quién puede eliminar permanentemente un viaje',
|
||||||
|
'perm.actionHint.trip_archive': 'Quién puede archivar o desarchivar un viaje',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Quién puede subir o cambiar la imagen de portada',
|
||||||
|
'perm.actionHint.member_manage': 'Quién puede invitar o eliminar miembros del viaje',
|
||||||
|
'perm.actionHint.file_upload': 'Quién puede subir archivos a un viaje',
|
||||||
|
'perm.actionHint.file_edit': 'Quién puede editar descripciones y enlaces de archivos',
|
||||||
|
'perm.actionHint.file_delete': 'Quién puede mover archivos a la papelera o eliminarlos permanentemente',
|
||||||
|
'perm.actionHint.place_edit': 'Quién puede añadir, editar o eliminar lugares',
|
||||||
|
'perm.actionHint.day_edit': 'Quién puede editar días, notas de días y asignaciones de lugares',
|
||||||
|
'perm.actionHint.reservation_edit': 'Quién puede crear, editar o eliminar reservas',
|
||||||
|
'perm.actionHint.budget_edit': 'Quién puede crear, editar o eliminar partidas del presupuesto',
|
||||||
|
'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas',
|
||||||
|
'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes',
|
||||||
|
'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default es
|
export default es
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const fr: Record<string, string> = {
|
|||||||
'common.edit': 'Modifier',
|
'common.edit': 'Modifier',
|
||||||
'common.add': 'Ajouter',
|
'common.add': 'Ajouter',
|
||||||
'common.loading': 'Chargement…',
|
'common.loading': 'Chargement…',
|
||||||
|
'common.import': 'Importer',
|
||||||
'common.error': 'Erreur',
|
'common.error': 'Erreur',
|
||||||
'common.back': 'Retour',
|
'common.back': 'Retour',
|
||||||
'common.all': 'Tout',
|
'common.all': 'Tout',
|
||||||
@@ -25,6 +26,14 @@ const fr: Record<string, string> = {
|
|||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Mot de passe',
|
'common.password': 'Mot de passe',
|
||||||
'common.saving': 'Enregistrement…',
|
'common.saving': 'Enregistrement…',
|
||||||
|
'common.saved': 'Enregistré',
|
||||||
|
'trips.reminder': 'Rappel',
|
||||||
|
'trips.reminderNone': 'Aucun',
|
||||||
|
'trips.reminderDay': 'jour',
|
||||||
|
'trips.reminderDays': 'jours',
|
||||||
|
'trips.reminderCustom': 'Personnalisé',
|
||||||
|
'trips.reminderDaysBefore': 'jours avant le départ',
|
||||||
|
'trips.reminderDisabledHint': 'Les rappels de voyage sont désactivés. Activez-les dans Admin > Paramètres > Notifications.',
|
||||||
'common.update': 'Mettre à jour',
|
'common.update': 'Mettre à jour',
|
||||||
'common.change': 'Modifier',
|
'common.change': 'Modifier',
|
||||||
'common.uploading': 'Import en cours…',
|
'common.uploading': 'Import en cours…',
|
||||||
@@ -149,9 +158,26 @@ const fr: Record<string, string> = {
|
|||||||
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
|
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
|
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
|
||||||
'settings.notifyWebhook': 'Notifications webhook',
|
'settings.notifyWebhook': 'Notifications webhook',
|
||||||
|
'settings.notificationsDisabled': 'Les notifications ne sont pas configurées. Demandez à un administrateur d\'activer les notifications par e-mail ou webhook.',
|
||||||
|
'settings.notificationsActive': 'Canal actif',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Les événements de notification sont configurés par votre administrateur.',
|
||||||
|
'admin.notifications.title': 'Notifications',
|
||||||
|
'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.',
|
||||||
|
'admin.notifications.none': 'Désactivé',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Événements de notification',
|
||||||
|
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
|
||||||
|
'admin.notifications.configureFirst': 'Configurez d\'abord les paramètres SMTP ou webhook ci-dessous, puis activez les événements.',
|
||||||
|
'admin.notifications.save': 'Enregistrer les paramètres de notification',
|
||||||
|
'admin.notifications.saved': 'Paramètres de notification enregistrés',
|
||||||
|
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Webhook de test envoyé avec succès',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Échec du webhook de test',
|
||||||
'admin.smtp.title': 'E-mail et notifications',
|
'admin.smtp.title': 'E-mail et notifications',
|
||||||
'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
|
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
|
||||||
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
||||||
|
'admin.webhook.hint': 'Envoyer des notifications vers un webhook externe (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
||||||
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
||||||
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
||||||
@@ -185,6 +211,31 @@ const fr: Record<string, string> = {
|
|||||||
'share.permCollab': 'Chat',
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'Activé',
|
'settings.on': 'Activé',
|
||||||
'settings.off': 'Désactivé',
|
'settings.off': 'Désactivé',
|
||||||
|
'settings.mcp.title': 'Configuration MCP',
|
||||||
|
'settings.mcp.endpoint': 'Point de terminaison MCP',
|
||||||
|
'settings.mcp.clientConfig': 'Configuration du client',
|
||||||
|
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
|
||||||
|
'settings.mcp.copy': 'Copier',
|
||||||
|
'settings.mcp.copied': 'Copié !',
|
||||||
|
'settings.mcp.apiTokens': 'Tokens API',
|
||||||
|
'settings.mcp.createToken': 'Créer un token',
|
||||||
|
'settings.mcp.noTokens': 'Aucun token pour l\'instant. Créez-en un pour connecter des clients MCP.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Créé',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Utilisé',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Supprimer le token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Ce token cessera de fonctionner immédiatement. Tout client MCP l\'utilisant perdra l\'accès.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Créer un token API',
|
||||||
|
'settings.mcp.modal.tokenName': 'Nom du token',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'ex. Claude Desktop, Ordinateur pro',
|
||||||
|
'settings.mcp.modal.creating': 'Création…',
|
||||||
|
'settings.mcp.modal.create': 'Créer le token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token créé',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Ce token ne sera affiché qu\'une seule fois. Copiez-le et conservez-le maintenant — il ne pourra pas être récupéré.',
|
||||||
|
'settings.mcp.modal.done': 'Terminé',
|
||||||
|
'settings.mcp.toast.created': 'Token créé',
|
||||||
|
'settings.mcp.toast.createError': 'Impossible de créer le token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token supprimé',
|
||||||
|
'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
|
||||||
'settings.account': 'Compte',
|
'settings.account': 'Compte',
|
||||||
'settings.username': 'Nom d\'utilisateur',
|
'settings.username': 'Nom d\'utilisateur',
|
||||||
'settings.email': 'E-mail',
|
'settings.email': 'E-mail',
|
||||||
@@ -192,6 +243,7 @@ const fr: Record<string, string> = {
|
|||||||
'settings.roleAdmin': 'Administrateur',
|
'settings.roleAdmin': 'Administrateur',
|
||||||
'settings.oidcLinked': 'Lié avec',
|
'settings.oidcLinked': 'Lié avec',
|
||||||
'settings.changePassword': 'Changer le mot de passe',
|
'settings.changePassword': 'Changer le mot de passe',
|
||||||
|
'settings.mustChangePassword': 'Vous devez changer votre mot de passe avant de continuer. Veuillez définir un nouveau mot de passe ci-dessous.',
|
||||||
'settings.currentPassword': 'Mot de passe actuel',
|
'settings.currentPassword': 'Mot de passe actuel',
|
||||||
'settings.currentPasswordRequired': 'Le mot de passe actuel est requis',
|
'settings.currentPasswordRequired': 'Le mot de passe actuel est requis',
|
||||||
'settings.newPassword': 'Nouveau mot de passe',
|
'settings.newPassword': 'Nouveau mot de passe',
|
||||||
@@ -200,7 +252,7 @@ const fr: Record<string, string> = {
|
|||||||
'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau',
|
'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau',
|
||||||
'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
|
'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||||
'settings.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
'settings.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
||||||
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre',
|
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial',
|
||||||
'settings.passwordChanged': 'Mot de passe modifié avec succès',
|
'settings.passwordChanged': 'Mot de passe modifié avec succès',
|
||||||
'settings.deleteAccount': 'Supprimer le compte',
|
'settings.deleteAccount': 'Supprimer le compte',
|
||||||
'settings.deleteAccountTitle': 'Supprimer votre compte ?',
|
'settings.deleteAccountTitle': 'Supprimer votre compte ?',
|
||||||
@@ -212,6 +264,14 @@ const fr: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Enregistrer le profil',
|
'settings.saveProfile': 'Enregistrer le profil',
|
||||||
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
|
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
|
||||||
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Votre administrateur exige l\'authentification à deux facteurs. Configurez une application d\'authentification ci-dessous avant de continuer.',
|
||||||
|
'settings.mfa.backupTitle': 'Codes de secours',
|
||||||
|
'settings.mfa.backupDescription': 'Utilisez ces codes à usage unique si vous perdez l\'accès à votre application d\'authentification.',
|
||||||
|
'settings.mfa.backupWarning': 'Enregistrez ces codes maintenant. Chaque code n\'est utilisable qu\'une seule fois.',
|
||||||
|
'settings.mfa.backupCopy': 'Copier les codes',
|
||||||
|
'settings.mfa.backupDownload': 'Télécharger TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Imprimer / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Codes de secours copiés',
|
||||||
'settings.mfa.enabled': '2FA est activé sur votre compte.',
|
'settings.mfa.enabled': '2FA est activé sur votre compte.',
|
||||||
'settings.mfa.disabled': '2FA n\'est pas activé.',
|
'settings.mfa.disabled': '2FA n\'est pas activé.',
|
||||||
'settings.mfa.setup': 'Configurer l\'authentificateur',
|
'settings.mfa.setup': 'Configurer l\'authentificateur',
|
||||||
@@ -263,6 +323,8 @@ const fr: Record<string, string> = {
|
|||||||
'login.signIn': 'Se connecter',
|
'login.signIn': 'Se connecter',
|
||||||
'login.createAdmin': 'Créer un compte administrateur',
|
'login.createAdmin': 'Créer un compte administrateur',
|
||||||
'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.',
|
'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.',
|
||||||
|
'login.setNewPassword': 'Définir un nouveau mot de passe',
|
||||||
|
'login.setNewPasswordHint': 'Vous devez changer votre mot de passe avant de continuer.',
|
||||||
'login.createAccount': 'Créer un compte',
|
'login.createAccount': 'Créer un compte',
|
||||||
'login.createAccountHint': 'Créez un nouveau compte.',
|
'login.createAccountHint': 'Créez un nouveau compte.',
|
||||||
'login.creating': 'Création…',
|
'login.creating': 'Création…',
|
||||||
@@ -289,7 +351,7 @@ const fr: Record<string, string> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
'register.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
||||||
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères',
|
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||||
'register.failed': 'Échec de l\'inscription',
|
'register.failed': 'Échec de l\'inscription',
|
||||||
'register.getStarted': 'Commencer',
|
'register.getStarted': 'Commencer',
|
||||||
'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.',
|
'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.',
|
||||||
@@ -364,6 +426,8 @@ const fr: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Paramètres',
|
'admin.tabs.settings': 'Paramètres',
|
||||||
'admin.allowRegistration': 'Autoriser les inscriptions',
|
'admin.allowRegistration': 'Autoriser les inscriptions',
|
||||||
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
|
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
|
||||||
|
'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
|
||||||
'admin.apiKeys': 'Clés API',
|
'admin.apiKeys': 'Clés API',
|
||||||
'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
|
'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
|
||||||
'admin.mapsKey': 'Clé API Google Maps',
|
'admin.mapsKey': 'Clé API Google Maps',
|
||||||
@@ -417,8 +481,10 @@ const fr: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Extensions',
|
'admin.tabs.addons': 'Extensions',
|
||||||
'admin.addons.title': 'Extensions',
|
'admin.addons.title': 'Extensions',
|
||||||
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
|
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
|
||||||
'admin.addons.catalog.memories.name': 'Souvenirs',
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Albums photo partagés pour chaque voyage',
|
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
|
||||||
'admin.addons.catalog.packing.name': 'Bagages',
|
'admin.addons.catalog.packing.name': 'Bagages',
|
||||||
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
|
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
|
||||||
'admin.addons.catalog.budget.name': 'Budget',
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
@@ -437,8 +503,10 @@ const fr: Record<string, string> = {
|
|||||||
'admin.addons.disabled': 'Désactivé',
|
'admin.addons.disabled': 'Désactivé',
|
||||||
'admin.addons.type.trip': 'Voyage',
|
'admin.addons.type.trip': 'Voyage',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Intégration',
|
||||||
'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage',
|
'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage',
|
||||||
'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale',
|
'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale',
|
||||||
|
'admin.addons.integrationHint': 'Services backend et intégrations API sans page dédiée',
|
||||||
'admin.addons.toast.updated': 'Extension mise à jour',
|
'admin.addons.toast.updated': 'Extension mise à jour',
|
||||||
'admin.addons.toast.error': 'Échec de la mise à jour de l\'extension',
|
'admin.addons.toast.error': 'Échec de la mise à jour de l\'extension',
|
||||||
'admin.addons.noAddons': 'Aucune extension disponible',
|
'admin.addons.noAddons': 'Aucune extension disponible',
|
||||||
@@ -468,6 +536,22 @@ const fr: Record<string, string> = {
|
|||||||
'admin.audit.col.ip': 'IP',
|
'admin.audit.col.ip': 'IP',
|
||||||
'admin.audit.col.details': 'Détails',
|
'admin.audit.col.details': 'Détails',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.title': 'Tokens MCP',
|
||||||
|
'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs',
|
||||||
|
'admin.mcpTokens.owner': 'Propriétaire',
|
||||||
|
'admin.mcpTokens.tokenName': 'Nom du token',
|
||||||
|
'admin.mcpTokens.created': 'Créé',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Dernière utilisation',
|
||||||
|
'admin.mcpTokens.never': 'Jamais',
|
||||||
|
'admin.mcpTokens.empty': 'Aucun token MCP n\'a encore été créé',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Supprimer le token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Ce token sera révoqué immédiatement. L\'utilisateur perdra l\'accès MCP via ce token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token supprimé',
|
||||||
|
'admin.mcpTokens.deleteError': 'Impossible de supprimer le token',
|
||||||
|
'admin.mcpTokens.loadError': 'Impossible de charger les tokens',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
'admin.github.title': 'Historique des versions',
|
'admin.github.title': 'Historique des versions',
|
||||||
@@ -636,9 +720,8 @@ const fr: Record<string, string> = {
|
|||||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
||||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||||
'atlas.addPoi': 'Ajouter un lieu',
|
'atlas.addPoi': 'Ajouter un lieu',
|
||||||
'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)',
|
'atlas.searchCountry': 'Rechercher un pays…',
|
||||||
'atlas.month': 'Mois',
|
'atlas.month': 'Mois',
|
||||||
'atlas.year': 'Année',
|
|
||||||
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
|
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
|
||||||
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
|
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
|
||||||
|
|
||||||
@@ -651,6 +734,7 @@ const fr: Record<string, string> = {
|
|||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Fichiers',
|
'trip.tabs.files': 'Fichiers',
|
||||||
'trip.loading': 'Chargement du voyage…',
|
'trip.loading': 'Chargement du voyage…',
|
||||||
|
'trip.loadingPhotos': 'Chargement des photos des lieux...',
|
||||||
'trip.mobilePlan': 'Plan',
|
'trip.mobilePlan': 'Plan',
|
||||||
'trip.mobilePlaces': 'Lieux',
|
'trip.mobilePlaces': 'Lieux',
|
||||||
'trip.toast.placeUpdated': 'Lieu mis à jour',
|
'trip.toast.placeUpdated': 'Lieu mis à jour',
|
||||||
@@ -697,9 +781,14 @@ const fr: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Ajouter un lieu/activité',
|
'places.addPlace': 'Ajouter un lieu/activité',
|
||||||
'places.importGpx': 'Importer GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||||
'places.gpxError': 'L\'import GPX a échoué',
|
'places.gpxError': 'L\'import GPX a échoué',
|
||||||
|
'places.importGoogleList': 'Liste Google',
|
||||||
|
'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
|
||||||
|
'places.googleListImported': '{count} lieux importés depuis "{list}"',
|
||||||
|
'places.googleListError': 'Impossible d\'importer la liste Google Maps',
|
||||||
|
'places.viewDetails': 'Voir les détails',
|
||||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||||
'places.all': 'Tous',
|
'places.all': 'Tous',
|
||||||
@@ -756,6 +845,7 @@ const fr: Record<string, string> = {
|
|||||||
'inspector.addRes': 'Réservation',
|
'inspector.addRes': 'Réservation',
|
||||||
'inspector.editRes': 'Modifier la réservation',
|
'inspector.editRes': 'Modifier la réservation',
|
||||||
'inspector.participants': 'Participants',
|
'inspector.participants': 'Participants',
|
||||||
|
'inspector.trackStats': 'Données du parcours',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Réservations',
|
'reservations.title': 'Réservations',
|
||||||
@@ -838,6 +928,7 @@ const fr: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'Exporter CSV',
|
||||||
'budget.emptyTitle': 'Aucun budget créé',
|
'budget.emptyTitle': 'Aucun budget créé',
|
||||||
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
|
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
|
||||||
'budget.emptyPlaceholder': 'Nom de la catégorie…',
|
'budget.emptyPlaceholder': 'Nom de la catégorie…',
|
||||||
@@ -852,6 +943,7 @@ const fr: Record<string, string> = {
|
|||||||
'budget.table.perDay': 'Par jour',
|
'budget.table.perDay': 'Par jour',
|
||||||
'budget.table.perPersonDay': 'P. p / Jour',
|
'budget.table.perPersonDay': 'P. p / Jour',
|
||||||
'budget.table.note': 'Note',
|
'budget.table.note': 'Note',
|
||||||
|
'budget.table.date': 'Date',
|
||||||
'budget.newEntry': 'Nouvelle entrée',
|
'budget.newEntry': 'Nouvelle entrée',
|
||||||
'budget.defaultEntry': 'Nouvelle entrée',
|
'budget.defaultEntry': 'Nouvelle entrée',
|
||||||
'budget.defaultCategory': 'Nouvelle catégorie',
|
'budget.defaultCategory': 'Nouvelle catégorie',
|
||||||
@@ -1245,6 +1337,7 @@ const fr: Record<string, string> = {
|
|||||||
'memories.immichUrl': 'URL du serveur Immich',
|
'memories.immichUrl': 'URL du serveur Immich',
|
||||||
'memories.immichApiKey': 'Clé API',
|
'memories.immichApiKey': 'Clé API',
|
||||||
'memories.testConnection': 'Tester la connexion',
|
'memories.testConnection': 'Tester la connexion',
|
||||||
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
'memories.connected': 'Connecté',
|
'memories.connected': 'Connecté',
|
||||||
'memories.disconnected': 'Non connecté',
|
'memories.disconnected': 'Non connecté',
|
||||||
'memories.connectionSuccess': 'Connecté à Immich',
|
'memories.connectionSuccess': 'Connecté à Immich',
|
||||||
@@ -1254,6 +1347,12 @@ const fr: Record<string, string> = {
|
|||||||
'memories.newest': 'Plus récentes',
|
'memories.newest': 'Plus récentes',
|
||||||
'memories.allLocations': 'Tous les lieux',
|
'memories.allLocations': 'Tous les lieux',
|
||||||
'memories.addPhotos': 'Ajouter des photos',
|
'memories.addPhotos': 'Ajouter des photos',
|
||||||
|
'memories.linkAlbum': 'Lier un album',
|
||||||
|
'memories.selectAlbum': 'Choisir un album Immich',
|
||||||
|
'memories.noAlbums': 'Aucun album trouvé',
|
||||||
|
'memories.syncAlbum': 'Synchroniser',
|
||||||
|
'memories.unlinkAlbum': 'Délier',
|
||||||
|
'memories.photos': 'photos',
|
||||||
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
|
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
|
||||||
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
||||||
'memories.selected': 'sélectionné(s)',
|
'memories.selected': 'sélectionné(s)',
|
||||||
@@ -1285,6 +1384,7 @@ const fr: Record<string, string> = {
|
|||||||
'collab.chat.today': 'Aujourd\'hui',
|
'collab.chat.today': 'Aujourd\'hui',
|
||||||
'collab.chat.yesterday': 'Hier',
|
'collab.chat.yesterday': 'Hier',
|
||||||
'collab.chat.deletedMessage': 'a supprimé un message',
|
'collab.chat.deletedMessage': 'a supprimé un message',
|
||||||
|
'collab.chat.reply': 'Répondre',
|
||||||
'collab.chat.loadMore': 'Charger les messages précédents',
|
'collab.chat.loadMore': 'Charger les messages précédents',
|
||||||
'collab.chat.justNow': 'à l\'instant',
|
'collab.chat.justNow': 'à l\'instant',
|
||||||
'collab.chat.minutesAgo': 'il y a {n} min',
|
'collab.chat.minutesAgo': 'il y a {n} min',
|
||||||
@@ -1335,6 +1435,55 @@ const fr: Record<string, string> = {
|
|||||||
'collab.polls.options': 'Options',
|
'collab.polls.options': 'Options',
|
||||||
'collab.polls.delete': 'Supprimer',
|
'collab.polls.delete': 'Supprimer',
|
||||||
'collab.polls.closedSection': 'Fermés',
|
'collab.polls.closedSection': 'Fermés',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permissions',
|
||||||
|
'perm.title': 'Paramètres des permissions',
|
||||||
|
'perm.subtitle': 'Contrôlez qui peut effectuer des actions dans l\'application',
|
||||||
|
'perm.saved': 'Paramètres des permissions enregistrés',
|
||||||
|
'perm.resetDefaults': 'Réinitialiser par défaut',
|
||||||
|
'perm.customized': 'personnalisé',
|
||||||
|
'perm.level.admin': 'Administrateur uniquement',
|
||||||
|
'perm.level.tripOwner': 'Propriétaire du voyage',
|
||||||
|
'perm.level.tripMember': 'Membres du voyage',
|
||||||
|
'perm.level.everybody': 'Tout le monde',
|
||||||
|
'perm.cat.trip': 'Gestion des voyages',
|
||||||
|
'perm.cat.members': 'Gestion des membres',
|
||||||
|
'perm.cat.files': 'Fichiers',
|
||||||
|
'perm.cat.content': 'Contenu et planning',
|
||||||
|
'perm.cat.extras': 'Budget, bagages et collaboration',
|
||||||
|
'perm.action.trip_create': 'Créer des voyages',
|
||||||
|
'perm.action.trip_edit': 'Modifier les détails du voyage',
|
||||||
|
'perm.action.trip_delete': 'Supprimer des voyages',
|
||||||
|
'perm.action.trip_archive': 'Archiver / désarchiver des voyages',
|
||||||
|
'perm.action.trip_cover_upload': 'Télécharger l\'image de couverture',
|
||||||
|
'perm.action.member_manage': 'Ajouter / supprimer des membres',
|
||||||
|
'perm.action.file_upload': 'Télécharger des fichiers',
|
||||||
|
'perm.action.file_edit': 'Modifier les métadonnées des fichiers',
|
||||||
|
'perm.action.file_delete': 'Supprimer des fichiers',
|
||||||
|
'perm.action.place_edit': 'Ajouter / modifier / supprimer des lieux',
|
||||||
|
'perm.action.day_edit': 'Modifier les jours, notes et affectations',
|
||||||
|
'perm.action.reservation_edit': 'Gérer les réservations',
|
||||||
|
'perm.action.budget_edit': 'Gérer le budget',
|
||||||
|
'perm.action.packing_edit': 'Gérer les listes de bagages',
|
||||||
|
'perm.action.collab_edit': 'Collaboration (notes, sondages, chat)',
|
||||||
|
'perm.action.share_manage': 'Gérer les liens de partage',
|
||||||
|
'perm.actionHint.trip_create': 'Qui peut créer de nouveaux voyages',
|
||||||
|
'perm.actionHint.trip_edit': 'Qui peut modifier le nom, les dates, la description et la devise du voyage',
|
||||||
|
'perm.actionHint.trip_delete': 'Qui peut supprimer définitivement un voyage',
|
||||||
|
'perm.actionHint.trip_archive': 'Qui peut archiver ou désarchiver un voyage',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Qui peut télécharger ou modifier l\'image de couverture',
|
||||||
|
'perm.actionHint.member_manage': 'Qui peut inviter ou supprimer des membres du voyage',
|
||||||
|
'perm.actionHint.file_upload': 'Qui peut télécharger des fichiers vers un voyage',
|
||||||
|
'perm.actionHint.file_edit': 'Qui peut modifier les descriptions et liens des fichiers',
|
||||||
|
'perm.actionHint.file_delete': 'Qui peut déplacer des fichiers vers la corbeille ou les supprimer définitivement',
|
||||||
|
'perm.actionHint.place_edit': 'Qui peut ajouter, modifier ou supprimer des lieux',
|
||||||
|
'perm.actionHint.day_edit': 'Qui peut modifier les jours, notes de jours et affectations de lieux',
|
||||||
|
'perm.actionHint.reservation_edit': 'Qui peut créer, modifier ou supprimer des réservations',
|
||||||
|
'perm.actionHint.budget_edit': 'Qui peut créer, modifier ou supprimer des éléments de budget',
|
||||||
|
'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs',
|
||||||
|
'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages',
|
||||||
|
'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fr
|
export default fr
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Szerkesztés',
|
'common.edit': 'Szerkesztés',
|
||||||
'common.add': 'Hozzáadás',
|
'common.add': 'Hozzáadás',
|
||||||
'common.loading': 'Betöltés...',
|
'common.loading': 'Betöltés...',
|
||||||
|
'common.import': 'Importálás',
|
||||||
'common.error': 'Hiba',
|
'common.error': 'Hiba',
|
||||||
'common.back': 'Vissza',
|
'common.back': 'Vissza',
|
||||||
'common.all': 'Összes',
|
'common.all': 'Összes',
|
||||||
@@ -25,6 +26,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Jelszó',
|
'common.password': 'Jelszó',
|
||||||
'common.saving': 'Mentés...',
|
'common.saving': 'Mentés...',
|
||||||
|
'common.saved': 'Mentve',
|
||||||
|
'trips.reminder': 'Emlékeztető',
|
||||||
|
'trips.reminderNone': 'Nincs',
|
||||||
|
'trips.reminderDay': 'nap',
|
||||||
|
'trips.reminderDays': 'nap',
|
||||||
|
'trips.reminderCustom': 'Egyéni',
|
||||||
|
'trips.reminderDaysBefore': 'nappal indulás előtt',
|
||||||
|
'trips.reminderDisabledHint': 'Az utazási emlékeztetők ki vannak kapcsolva. Kapcsold be az Admin > Beállítások > Értesítések menüben.',
|
||||||
'common.update': 'Frissítés',
|
'common.update': 'Frissítés',
|
||||||
'common.change': 'Módosítás',
|
'common.change': 'Módosítás',
|
||||||
'common.uploading': 'Feltöltés…',
|
'common.uploading': 'Feltöltés…',
|
||||||
@@ -149,8 +158,36 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
|
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
|
'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
|
||||||
'settings.notifyWebhook': 'Webhook értesítések',
|
'settings.notifyWebhook': 'Webhook értesítések',
|
||||||
|
'settings.notificationsDisabled': 'Az értesítések nincsenek beállítva. Kérje meg a rendszergazdát, hogy engedélyezze az e-mail vagy webhook értesítéseket.',
|
||||||
|
'settings.notificationsActive': 'Aktív csatorna',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Az értesítési eseményeket az adminisztrátor konfigurálja.',
|
||||||
'settings.on': 'Be',
|
'settings.on': 'Be',
|
||||||
'settings.off': 'Ki',
|
'settings.off': 'Ki',
|
||||||
|
'settings.mcp.title': 'MCP konfiguráció',
|
||||||
|
'settings.mcp.endpoint': 'MCP végpont',
|
||||||
|
'settings.mcp.clientConfig': 'Kliens konfiguráció',
|
||||||
|
'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
|
||||||
|
'settings.mcp.copy': 'Másolás',
|
||||||
|
'settings.mcp.copied': 'Másolva!',
|
||||||
|
'settings.mcp.apiTokens': 'API tokenek',
|
||||||
|
'settings.mcp.createToken': 'Új token létrehozása',
|
||||||
|
'settings.mcp.noTokens': 'Még nincsenek tokenek. Hozzon létre egyet MCP kliensek csatlakoztatásához.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Létrehozva',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Használva',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Token törlése',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Ez a token azonnal érvénytelenné válik. Minden MCP kliens, amely használja, elveszíti a hozzáférést.',
|
||||||
|
'settings.mcp.modal.createTitle': 'API token létrehozása',
|
||||||
|
'settings.mcp.modal.tokenName': 'Token neve',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'pl. Claude Desktop, Munkahelyi laptop',
|
||||||
|
'settings.mcp.modal.creating': 'Létrehozás…',
|
||||||
|
'settings.mcp.modal.create': 'Token létrehozása',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token létrehozva',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Ez a token csak egyszer jelenik meg. Másolja és mentse el most — nem lehet visszaállítani.',
|
||||||
|
'settings.mcp.modal.done': 'Kész',
|
||||||
|
'settings.mcp.toast.created': 'Token létrehozva',
|
||||||
|
'settings.mcp.toast.createError': 'Nem sikerült létrehozni a tokent',
|
||||||
|
'settings.mcp.toast.deleted': 'Token törölve',
|
||||||
|
'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
|
||||||
'settings.account': 'Fiók',
|
'settings.account': 'Fiók',
|
||||||
'settings.username': 'Felhasználónév',
|
'settings.username': 'Felhasználónév',
|
||||||
'settings.email': 'E-mail',
|
'settings.email': 'E-mail',
|
||||||
@@ -165,7 +202,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót',
|
'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót',
|
||||||
'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező',
|
'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező',
|
||||||
'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||||
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot',
|
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert',
|
||||||
'settings.passwordMismatch': 'A jelszavak nem egyeznek',
|
'settings.passwordMismatch': 'A jelszavak nem egyeznek',
|
||||||
'settings.passwordChanged': 'Jelszó sikeresen módosítva',
|
'settings.passwordChanged': 'Jelszó sikeresen módosítva',
|
||||||
'settings.deleteAccount': 'Törlés',
|
'settings.deleteAccount': 'Törlés',
|
||||||
@@ -187,6 +224,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Feltöltés sikertelen',
|
'settings.avatarError': 'Feltöltés sikertelen',
|
||||||
'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
|
'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
|
||||||
'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
|
'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'A rendszergazda kétlépcsős hitelesítést ír elő. Állíts be hitelesítő alkalmazást lent, mielőtt továbblépnél.',
|
||||||
|
'settings.mfa.backupTitle': 'Tartalék kódok',
|
||||||
|
'settings.mfa.backupDescription': 'Használd ezeket az egyszer használatos kódokat, ha elveszíted a hozzáférést a hitelesítő alkalmazásodhoz.',
|
||||||
|
'settings.mfa.backupWarning': 'Mentsd el ezeket most. Minden kód csak egyszer használható.',
|
||||||
|
'settings.mfa.backupCopy': 'Kódok másolása',
|
||||||
|
'settings.mfa.backupDownload': 'TXT letöltése',
|
||||||
|
'settings.mfa.backupPrint': 'Nyomtatás / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Tartalék kódok másolva',
|
||||||
'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
|
'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
|
||||||
'settings.mfa.disabled': '2FA nincs engedélyezve.',
|
'settings.mfa.disabled': '2FA nincs engedélyezve.',
|
||||||
'settings.mfa.setup': 'Hitelesítő beállítása',
|
'settings.mfa.setup': 'Hitelesítő beállítása',
|
||||||
@@ -201,9 +246,24 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
|
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
|
||||||
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
|
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
|
||||||
'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
|
'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
|
||||||
|
'settings.mustChangePassword': 'A folytatás előtt meg kell változtatnod a jelszavad. Kérjük, adj meg egy új jelszót alább.',
|
||||||
|
'admin.notifications.title': 'Értesítések',
|
||||||
|
'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.',
|
||||||
|
'admin.notifications.none': 'Kikapcsolva',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Értesítési események',
|
||||||
|
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
|
||||||
|
'admin.notifications.configureFirst': 'Először konfiguráld az SMTP vagy webhook beállításokat lent, majd engedélyezd az eseményeket.',
|
||||||
|
'admin.notifications.save': 'Értesítési beállítások mentése',
|
||||||
|
'admin.notifications.saved': 'Értesítési beállítások mentve',
|
||||||
|
'admin.notifications.testWebhook': 'Teszt webhook küldése',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Teszt webhook sikeresen elküldve',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Teszt webhook küldése sikertelen',
|
||||||
'admin.smtp.title': 'E-mail és értesítések',
|
'admin.smtp.title': 'E-mail és értesítések',
|
||||||
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítésekhez. Opcionális: Webhook URL Discordhoz, Slackhez stb.',
|
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
|
||||||
'admin.smtp.testButton': 'Teszt e-mail küldése',
|
'admin.smtp.testButton': 'Teszt e-mail küldése',
|
||||||
|
'admin.webhook.hint': 'Értesítések küldése külső webhookra (Discord, Slack stb.).',
|
||||||
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
|
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
|
||||||
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
|
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
|
||||||
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
|
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
|
||||||
@@ -263,6 +323,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Bejelentkezés',
|
'login.signIn': 'Bejelentkezés',
|
||||||
'login.createAdmin': 'Admin fiók létrehozása',
|
'login.createAdmin': 'Admin fiók létrehozása',
|
||||||
'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.',
|
'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.',
|
||||||
|
'login.setNewPassword': 'Új jelszó beállítása',
|
||||||
|
'login.setNewPasswordHint': 'A folytatás előtt meg kell változtatnia a jelszavát.',
|
||||||
'login.createAccount': 'Fiók létrehozása',
|
'login.createAccount': 'Fiók létrehozása',
|
||||||
'login.createAccountHint': 'Új fiók regisztrálása.',
|
'login.createAccountHint': 'Új fiók regisztrálása.',
|
||||||
'login.creating': 'Létrehozás…',
|
'login.creating': 'Létrehozás…',
|
||||||
@@ -289,7 +351,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Regisztráció
|
// Regisztráció
|
||||||
'register.passwordMismatch': 'A jelszavak nem egyeznek',
|
'register.passwordMismatch': 'A jelszavak nem egyeznek',
|
||||||
'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie',
|
'register.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||||
'register.failed': 'Regisztráció sikertelen',
|
'register.failed': 'Regisztráció sikertelen',
|
||||||
'register.getStarted': 'Kezdjük',
|
'register.getStarted': 'Kezdjük',
|
||||||
'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.',
|
'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.',
|
||||||
@@ -364,6 +426,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Beállítások',
|
'admin.tabs.settings': 'Beállítások',
|
||||||
'admin.allowRegistration': 'Regisztráció engedélyezése',
|
'admin.allowRegistration': 'Regisztráció engedélyezése',
|
||||||
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
|
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
|
||||||
|
'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
|
||||||
|
'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
|
||||||
'admin.apiKeys': 'API kulcsok',
|
'admin.apiKeys': 'API kulcsok',
|
||||||
'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
|
'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
|
||||||
'admin.mapsKey': 'Google Maps API kulcs',
|
'admin.mapsKey': 'Google Maps API kulcs',
|
||||||
@@ -432,14 +496,18 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.collab.description': 'Valós idejű jegyzetek, szavazások és csevegés az utazás tervezéséhez',
|
'admin.addons.catalog.collab.description': 'Valós idejű jegyzetek, szavazások és csevegés az utazás tervezéséhez',
|
||||||
'admin.addons.catalog.memories.name': 'Fotók (Immich)',
|
'admin.addons.catalog.memories.name': 'Fotók (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül',
|
'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol AI asszisztens integrációhoz',
|
||||||
'admin.addons.subtitleBefore': 'Funkciók engedélyezése vagy letiltása a ',
|
'admin.addons.subtitleBefore': 'Funkciók engedélyezése vagy letiltása a ',
|
||||||
'admin.addons.subtitleAfter': ' testreszabásához.',
|
'admin.addons.subtitleAfter': ' testreszabásához.',
|
||||||
'admin.addons.enabled': 'Engedélyezve',
|
'admin.addons.enabled': 'Engedélyezve',
|
||||||
'admin.addons.disabled': 'Letiltva',
|
'admin.addons.disabled': 'Letiltva',
|
||||||
'admin.addons.type.trip': 'Utazás',
|
'admin.addons.type.trip': 'Utazás',
|
||||||
'admin.addons.type.global': 'Globális',
|
'admin.addons.type.global': 'Globális',
|
||||||
|
'admin.addons.type.integration': 'Integráció',
|
||||||
'admin.addons.tripHint': 'Fülként érhető el minden utazáson belül',
|
'admin.addons.tripHint': 'Fülként érhető el minden utazáson belül',
|
||||||
'admin.addons.globalHint': 'Önálló szekcióként elérhető a fő navigációban',
|
'admin.addons.globalHint': 'Önálló szekcióként elérhető a fő navigációban',
|
||||||
|
'admin.addons.integrationHint': 'Háttérszolgáltatások és API integrációk dedikált oldal nélkül',
|
||||||
'admin.addons.toast.updated': 'Bővítmény frissítve',
|
'admin.addons.toast.updated': 'Bővítmény frissítve',
|
||||||
'admin.addons.toast.error': 'Nem sikerült frissíteni a bővítményt',
|
'admin.addons.toast.error': 'Nem sikerült frissíteni a bővítményt',
|
||||||
'admin.addons.noAddons': 'Nincsenek elérhető bővítmények',
|
'admin.addons.noAddons': 'Nincsenek elérhető bővítmények',
|
||||||
@@ -469,6 +537,22 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.audit.col.ip': 'IP',
|
'admin.audit.col.ip': 'IP',
|
||||||
'admin.audit.col.details': 'Részletek',
|
'admin.audit.col.details': 'Részletek',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP tokenek',
|
||||||
|
'admin.mcpTokens.title': 'MCP tokenek',
|
||||||
|
'admin.mcpTokens.subtitle': 'Összes felhasználó API tokeneinek kezelése',
|
||||||
|
'admin.mcpTokens.owner': 'Tulajdonos',
|
||||||
|
'admin.mcpTokens.tokenName': 'Token neve',
|
||||||
|
'admin.mcpTokens.created': 'Létrehozva',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Utoljára használva',
|
||||||
|
'admin.mcpTokens.never': 'Soha',
|
||||||
|
'admin.mcpTokens.empty': 'Még nem hoztak létre MCP tokeneket',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Token törlése',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Ez a token azonnal érvénytelenítésre kerül. A felhasználó elveszíti az MCP hozzáférést ezen a tokenen keresztül.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token törölve',
|
||||||
|
'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent',
|
||||||
|
'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
'admin.github.title': 'Frissítési előzmények',
|
'admin.github.title': 'Frissítési előzmények',
|
||||||
@@ -601,6 +685,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
||||||
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
||||||
'atlas.addPoi': 'Hely hozzáadása',
|
'atlas.addPoi': 'Hely hozzáadása',
|
||||||
|
'atlas.searchCountry': 'Ország keresése...',
|
||||||
'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
|
'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
|
||||||
'atlas.month': 'Hónap',
|
'atlas.month': 'Hónap',
|
||||||
'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
|
'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
|
||||||
@@ -608,7 +693,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Statisztikák',
|
'atlas.statsTab': 'Statisztikák',
|
||||||
'atlas.bucketTab': 'Bakancslista',
|
'atlas.bucketTab': 'Bakancslista',
|
||||||
'atlas.addBucket': 'Hozzáadás a bakancslistához',
|
'atlas.addBucket': 'Hozzáadás a bakancslistához',
|
||||||
'atlas.bucketNamePlaceholder': 'Hely vagy úti cél...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Jegyzetek (opcionális)',
|
'atlas.bucketNotesPlaceholder': 'Jegyzetek (opcionális)',
|
||||||
'atlas.bucketEmpty': 'A bakancslistád üres',
|
'atlas.bucketEmpty': 'A bakancslistád üres',
|
||||||
'atlas.bucketEmptyHint': 'Adj hozzá helyeket, ahová álmodsz eljutni',
|
'atlas.bucketEmptyHint': 'Adj hozzá helyeket, ahová álmodsz eljutni',
|
||||||
@@ -663,6 +747,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
||||||
'trip.toast.deleted': 'Törölve',
|
'trip.toast.deleted': 'Törölve',
|
||||||
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
||||||
|
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
|
||||||
|
|
||||||
// Napi terv oldalsáv
|
// Napi terv oldalsáv
|
||||||
'dayplan.emptyDay': 'Nincs tervezett hely erre a napra',
|
'dayplan.emptyDay': 'Nincs tervezett hely erre a napra',
|
||||||
@@ -697,10 +782,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Helyek oldalsáv
|
// Helyek oldalsáv
|
||||||
'places.addPlace': 'Hely/Tevékenység hozzáadása',
|
'places.addPlace': 'Hely/Tevékenység hozzáadása',
|
||||||
'places.importGpx': 'GPX importálás',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||||
'places.urlResolved': 'Hely importálva URL-ből',
|
'places.urlResolved': 'Hely importálva URL-ből',
|
||||||
'places.gpxError': 'GPX importálás sikertelen',
|
'places.gpxError': 'GPX importálás sikertelen',
|
||||||
|
'places.importGoogleList': 'Google Lista',
|
||||||
|
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
|
||||||
|
'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
|
||||||
|
'places.googleListError': 'Google Maps lista importalasa sikertelen',
|
||||||
|
'places.viewDetails': 'Részletek megtekintése',
|
||||||
'places.assignToDay': 'Melyik naphoz adod?',
|
'places.assignToDay': 'Melyik naphoz adod?',
|
||||||
'places.all': 'Összes',
|
'places.all': 'Összes',
|
||||||
'places.unplanned': 'Nem tervezett',
|
'places.unplanned': 'Nem tervezett',
|
||||||
@@ -756,6 +846,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Foglalás',
|
'inspector.addRes': 'Foglalás',
|
||||||
'inspector.editRes': 'Foglalás szerkesztése',
|
'inspector.editRes': 'Foglalás szerkesztése',
|
||||||
'inspector.participants': 'Résztvevők',
|
'inspector.participants': 'Résztvevők',
|
||||||
|
'inspector.trackStats': 'Útvonal adatok',
|
||||||
|
|
||||||
// Foglalások
|
// Foglalások
|
||||||
'reservations.title': 'Foglalások',
|
'reservations.title': 'Foglalások',
|
||||||
@@ -838,6 +929,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Költségvetés
|
// Költségvetés
|
||||||
'budget.title': 'Költségvetés',
|
'budget.title': 'Költségvetés',
|
||||||
|
'budget.exportCsv': 'CSV exportálás',
|
||||||
'budget.emptyTitle': 'Még nincs költségvetés létrehozva',
|
'budget.emptyTitle': 'Még nincs költségvetés létrehozva',
|
||||||
'budget.emptyText': 'Hozz létre kategóriákat és bejegyzéseket az utazási költségvetés tervezéséhez',
|
'budget.emptyText': 'Hozz létre kategóriákat és bejegyzéseket az utazási költségvetés tervezéséhez',
|
||||||
'budget.emptyPlaceholder': 'Kategória neve...',
|
'budget.emptyPlaceholder': 'Kategória neve...',
|
||||||
@@ -852,6 +944,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Naponta',
|
'budget.table.perDay': 'Naponta',
|
||||||
'budget.table.perPersonDay': 'Fő / Nap',
|
'budget.table.perPersonDay': 'Fő / Nap',
|
||||||
'budget.table.note': 'Megjegyzés',
|
'budget.table.note': 'Megjegyzés',
|
||||||
|
'budget.table.date': 'Dátum',
|
||||||
'budget.newEntry': 'Új bejegyzés',
|
'budget.newEntry': 'Új bejegyzés',
|
||||||
'budget.defaultEntry': 'Új bejegyzés',
|
'budget.defaultEntry': 'Új bejegyzés',
|
||||||
'budget.defaultCategory': 'Új kategória',
|
'budget.defaultCategory': 'Új kategória',
|
||||||
@@ -1246,6 +1339,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Ma',
|
'collab.chat.today': 'Ma',
|
||||||
'collab.chat.yesterday': 'Tegnap',
|
'collab.chat.yesterday': 'Tegnap',
|
||||||
'collab.chat.deletedMessage': 'törölt egy üzenetet',
|
'collab.chat.deletedMessage': 'törölt egy üzenetet',
|
||||||
|
'collab.chat.reply': 'Válasz',
|
||||||
'collab.chat.loadMore': 'Korábbi üzenetek betöltése',
|
'collab.chat.loadMore': 'Korábbi üzenetek betöltése',
|
||||||
'collab.chat.justNow': 'éppen most',
|
'collab.chat.justNow': 'éppen most',
|
||||||
'collab.chat.minutesAgo': '{n} perce',
|
'collab.chat.minutesAgo': '{n} perce',
|
||||||
@@ -1314,12 +1408,19 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'Immich szerver URL',
|
'memories.immichUrl': 'Immich szerver URL',
|
||||||
'memories.immichApiKey': 'API kulcs',
|
'memories.immichApiKey': 'API kulcs',
|
||||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||||
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
'memories.connected': 'Csatlakoztatva',
|
'memories.connected': 'Csatlakoztatva',
|
||||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||||
'memories.connectionSuccess': 'Csatlakozva az Immichhez',
|
'memories.connectionSuccess': 'Csatlakozva az Immichhez',
|
||||||
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
|
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
|
||||||
'memories.saved': 'Immich beállítások mentve',
|
'memories.saved': 'Immich beállítások mentve',
|
||||||
'memories.addPhotos': 'Fotók hozzáadása',
|
'memories.addPhotos': 'Fotók hozzáadása',
|
||||||
|
'memories.linkAlbum': 'Album csatolása',
|
||||||
|
'memories.selectAlbum': 'Immich album kiválasztása',
|
||||||
|
'memories.noAlbums': 'Nem található album',
|
||||||
|
'memories.syncAlbum': 'Album szinkronizálása',
|
||||||
|
'memories.unlinkAlbum': 'Leválasztás',
|
||||||
|
'memories.photos': 'fotó',
|
||||||
'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
|
'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
|
||||||
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
|
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
|
||||||
'memories.selected': 'kijelölve',
|
'memories.selected': 'kijelölve',
|
||||||
@@ -1335,6 +1436,55 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.confirmShareTitle': 'Megosztás az utazótársakkal?',
|
'memories.confirmShareTitle': 'Megosztás az utazótársakkal?',
|
||||||
'memories.confirmShareHint': '{count} fotó lesz látható az utazás összes tagja számára. Később egyenként is priváttá teheted őket.',
|
'memories.confirmShareHint': '{count} fotó lesz látható az utazás összes tagja számára. Később egyenként is priváttá teheted őket.',
|
||||||
'memories.confirmShareButton': 'Fotók megosztása',
|
'memories.confirmShareButton': 'Fotók megosztása',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Jogosultságok',
|
||||||
|
'perm.title': 'Jogosultsági beállítások',
|
||||||
|
'perm.subtitle': 'Szabályozd, ki milyen műveleteket végezhet az alkalmazásban',
|
||||||
|
'perm.saved': 'Jogosultsági beállítások mentve',
|
||||||
|
'perm.resetDefaults': 'Alapértelmezések visszaállítása',
|
||||||
|
'perm.customized': 'testreszabott',
|
||||||
|
'perm.level.admin': 'Csak adminisztrátor',
|
||||||
|
'perm.level.tripOwner': 'Utazás tulajdonosa',
|
||||||
|
'perm.level.tripMember': 'Utazás tagjai',
|
||||||
|
'perm.level.everybody': 'Mindenki',
|
||||||
|
'perm.cat.trip': 'Utazáskezelés',
|
||||||
|
'perm.cat.members': 'Tagkezelés',
|
||||||
|
'perm.cat.files': 'Fájlok',
|
||||||
|
'perm.cat.content': 'Tartalom és menetrend',
|
||||||
|
'perm.cat.extras': 'Költségvetés, csomagolás és együttműködés',
|
||||||
|
'perm.action.trip_create': 'Utazások létrehozása',
|
||||||
|
'perm.action.trip_edit': 'Utazás részleteinek szerkesztése',
|
||||||
|
'perm.action.trip_delete': 'Utazások törlése',
|
||||||
|
'perm.action.trip_archive': 'Utazások archiválása / visszaállítása',
|
||||||
|
'perm.action.trip_cover_upload': 'Borítókép feltöltése',
|
||||||
|
'perm.action.member_manage': 'Tagok hozzáadása / eltávolítása',
|
||||||
|
'perm.action.file_upload': 'Fájlok feltöltése',
|
||||||
|
'perm.action.file_edit': 'Fájl metaadatok szerkesztése',
|
||||||
|
'perm.action.file_delete': 'Fájlok törlése',
|
||||||
|
'perm.action.place_edit': 'Helyek hozzáadása / szerkesztése / törlése',
|
||||||
|
'perm.action.day_edit': 'Napok, jegyzetek és hozzárendelések szerkesztése',
|
||||||
|
'perm.action.reservation_edit': 'Foglalások kezelése',
|
||||||
|
'perm.action.budget_edit': 'Költségvetés kezelése',
|
||||||
|
'perm.action.packing_edit': 'Csomagolási listák kezelése',
|
||||||
|
'perm.action.collab_edit': 'Együttműködés (jegyzetek, szavazások, chat)',
|
||||||
|
'perm.action.share_manage': 'Megosztási linkek kezelése',
|
||||||
|
'perm.actionHint.trip_create': 'Ki hozhat létre új utazásokat',
|
||||||
|
'perm.actionHint.trip_edit': 'Ki módosíthatja az utazás nevét, dátumait, leírását és pénznemét',
|
||||||
|
'perm.actionHint.trip_delete': 'Ki törölhet véglegesen egy utazást',
|
||||||
|
'perm.actionHint.trip_archive': 'Ki archiválhat vagy állíthat vissza egy utazást',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Ki tölthet fel vagy módosíthat borítóképet',
|
||||||
|
'perm.actionHint.member_manage': 'Ki hívhat meg vagy távolíthat el utazás tagokat',
|
||||||
|
'perm.actionHint.file_upload': 'Ki tölthet fel fájlokat egy utazáshoz',
|
||||||
|
'perm.actionHint.file_edit': 'Ki szerkesztheti a fájlok leírásait és linkjeit',
|
||||||
|
'perm.actionHint.file_delete': 'Ki helyezhet fájlokat a kukába vagy törölheti véglegesen',
|
||||||
|
'perm.actionHint.place_edit': 'Ki adhat hozzá, szerkeszthet vagy törölhet helyeket',
|
||||||
|
'perm.actionHint.day_edit': 'Ki szerkesztheti a napokat, napi jegyzeteket és hely-hozzárendeléseket',
|
||||||
|
'perm.actionHint.reservation_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet foglalásokat',
|
||||||
|
'perm.actionHint.budget_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet költségvetési tételeket',
|
||||||
|
'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat',
|
||||||
|
'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket',
|
||||||
|
'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hu
|
export default hu
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.edit': 'Modifica',
|
'common.edit': 'Modifica',
|
||||||
'common.add': 'Aggiungi',
|
'common.add': 'Aggiungi',
|
||||||
'common.loading': 'Caricamento...',
|
'common.loading': 'Caricamento...',
|
||||||
|
'common.import': 'Importa',
|
||||||
'common.error': 'Errore',
|
'common.error': 'Errore',
|
||||||
'common.back': 'Indietro',
|
'common.back': 'Indietro',
|
||||||
'common.all': 'Tutti',
|
'common.all': 'Tutti',
|
||||||
@@ -25,6 +26,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
'common.saving': 'Salvataggio...',
|
'common.saving': 'Salvataggio...',
|
||||||
|
'common.saved': 'Salvato',
|
||||||
|
'trips.reminder': 'Promemoria',
|
||||||
|
'trips.reminderNone': 'Nessuno',
|
||||||
|
'trips.reminderDay': 'giorno',
|
||||||
|
'trips.reminderDays': 'giorni',
|
||||||
|
'trips.reminderCustom': 'Personalizzato',
|
||||||
|
'trips.reminderDaysBefore': 'giorni prima della partenza',
|
||||||
|
'trips.reminderDisabledHint': 'I promemoria dei viaggi sono disabilitati. Abilitali in Admin > Impostazioni > Notifiche.',
|
||||||
'common.update': 'Aggiorna',
|
'common.update': 'Aggiorna',
|
||||||
'common.change': 'Cambia',
|
'common.change': 'Cambia',
|
||||||
'common.uploading': 'Caricamento…',
|
'common.uploading': 'Caricamento…',
|
||||||
@@ -149,8 +158,36 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
|
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
|
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
|
||||||
'settings.notifyWebhook': 'Notifiche webhook',
|
'settings.notifyWebhook': 'Notifiche webhook',
|
||||||
|
'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.',
|
||||||
|
'settings.notificationsActive': 'Canale attivo',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Gli eventi di notifica sono configurati dall\'amministratore.',
|
||||||
'settings.on': 'On',
|
'settings.on': 'On',
|
||||||
'settings.off': 'Off',
|
'settings.off': 'Off',
|
||||||
|
'settings.mcp.title': 'Configurazione MCP',
|
||||||
|
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||||
|
'settings.mcp.clientConfig': 'Configurazione client',
|
||||||
|
'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
|
||||||
|
'settings.mcp.copy': 'Copia',
|
||||||
|
'settings.mcp.copied': 'Copiato!',
|
||||||
|
'settings.mcp.apiTokens': 'Token API',
|
||||||
|
'settings.mcp.createToken': 'Crea nuovo token',
|
||||||
|
'settings.mcp.noTokens': 'Nessun token ancora. Creane uno per connettere i client MCP.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Creato',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Utilizzato',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Elimina token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Questo token smetterà di funzionare immediatamente. Qualsiasi client MCP che lo utilizza perderà l\'accesso.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Crea token API',
|
||||||
|
'settings.mcp.modal.tokenName': 'Nome del token',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'es. Claude Desktop, Laptop di lavoro',
|
||||||
|
'settings.mcp.modal.creating': 'Creazione…',
|
||||||
|
'settings.mcp.modal.create': 'Crea token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token creato',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Questo token verrà mostrato solo una volta. Copialo e salvalo ora — non può essere recuperato.',
|
||||||
|
'settings.mcp.modal.done': 'Fatto',
|
||||||
|
'settings.mcp.toast.created': 'Token creato',
|
||||||
|
'settings.mcp.toast.createError': 'Impossibile creare il token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token eliminato',
|
||||||
|
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.username': 'Username',
|
'settings.username': 'Username',
|
||||||
'settings.email': 'Email',
|
'settings.email': 'Email',
|
||||||
@@ -166,7 +203,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
|
'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
|
||||||
'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
|
'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
|
||||||
'settings.passwordMismatch': 'Le password non corrispondono',
|
'settings.passwordMismatch': 'Le password non corrispondono',
|
||||||
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero',
|
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale',
|
||||||
'settings.passwordChanged': 'Password cambiata con successo',
|
'settings.passwordChanged': 'Password cambiata con successo',
|
||||||
'settings.deleteAccount': 'Elimina account',
|
'settings.deleteAccount': 'Elimina account',
|
||||||
'settings.deleteAccountTitle': 'Eliminare il tuo account?',
|
'settings.deleteAccountTitle': 'Eliminare il tuo account?',
|
||||||
@@ -187,6 +224,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Impossibile caricare',
|
'settings.avatarError': 'Impossibile caricare',
|
||||||
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
|
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
|
||||||
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
|
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.',
|
||||||
|
'settings.mfa.backupTitle': 'Codici di backup',
|
||||||
|
'settings.mfa.backupDescription': 'Usa questi codici monouso se perdi l\'accesso alla tua app authenticator.',
|
||||||
|
'settings.mfa.backupWarning': 'Salvali adesso. Ogni codice può essere usato una sola volta.',
|
||||||
|
'settings.mfa.backupCopy': 'Copia codici',
|
||||||
|
'settings.mfa.backupDownload': 'Scarica TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Stampa / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Codici di backup copiati',
|
||||||
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
|
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
|
||||||
'settings.mfa.disabled': 'La 2FA non è abilitata.',
|
'settings.mfa.disabled': 'La 2FA non è abilitata.',
|
||||||
'settings.mfa.setup': 'Configura authenticator',
|
'settings.mfa.setup': 'Configura authenticator',
|
||||||
@@ -201,9 +246,24 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
|
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
|
||||||
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
|
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
|
||||||
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
|
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
|
||||||
|
'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.',
|
||||||
|
'admin.notifications.title': 'Notifiche',
|
||||||
|
'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.',
|
||||||
|
'admin.notifications.none': 'Disattivato',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Eventi di notifica',
|
||||||
|
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
|
||||||
|
'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
|
||||||
|
'admin.notifications.save': 'Salva impostazioni notifiche',
|
||||||
|
'admin.notifications.saved': 'Impostazioni notifiche salvate',
|
||||||
|
'admin.notifications.testWebhook': 'Invia webhook di test',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Webhook di test inviato con successo',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Invio webhook di test fallito',
|
||||||
'admin.smtp.title': 'Email e notifiche',
|
'admin.smtp.title': 'Email e notifiche',
|
||||||
'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.',
|
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
|
||||||
'admin.smtp.testButton': 'Invia email di prova',
|
'admin.smtp.testButton': 'Invia email di prova',
|
||||||
|
'admin.webhook.hint': 'Invia notifiche a un webhook esterno (Discord, Slack, ecc.).',
|
||||||
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
|
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
|
||||||
'admin.smtp.testFailed': 'Invio email di prova fallito',
|
'admin.smtp.testFailed': 'Invio email di prova fallito',
|
||||||
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
|
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
|
||||||
@@ -263,6 +323,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.signIn': 'Accedi',
|
'login.signIn': 'Accedi',
|
||||||
'login.createAdmin': 'Crea Account Amministratore',
|
'login.createAdmin': 'Crea Account Amministratore',
|
||||||
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
|
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
|
||||||
|
'login.setNewPassword': 'Imposta nuova password',
|
||||||
|
'login.setNewPasswordHint': 'Devi cambiare la password prima di continuare.',
|
||||||
'login.createAccount': 'Crea Account',
|
'login.createAccount': 'Crea Account',
|
||||||
'login.createAccountHint': 'Registra un nuovo account.',
|
'login.createAccountHint': 'Registra un nuovo account.',
|
||||||
'login.creating': 'Creazione in corso…',
|
'login.creating': 'Creazione in corso…',
|
||||||
@@ -289,7 +351,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Le password non corrispondono',
|
'register.passwordMismatch': 'Le password non corrispondono',
|
||||||
'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri',
|
'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
|
||||||
'register.failed': 'Registrazione fallita',
|
'register.failed': 'Registrazione fallita',
|
||||||
'register.getStarted': 'Inizia',
|
'register.getStarted': 'Inizia',
|
||||||
'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',
|
'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',
|
||||||
@@ -364,6 +426,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Impostazioni',
|
'admin.tabs.settings': 'Impostazioni',
|
||||||
'admin.allowRegistration': 'Consenti Registrazione',
|
'admin.allowRegistration': 'Consenti Registrazione',
|
||||||
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
|
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
|
||||||
|
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
|
||||||
'admin.apiKeys': 'Chiavi API',
|
'admin.apiKeys': 'Chiavi API',
|
||||||
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
|
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
|
||||||
'admin.mapsKey': 'Chiave API Google Maps',
|
'admin.mapsKey': 'Chiave API Google Maps',
|
||||||
@@ -431,14 +495,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.collab.description': 'Note, sondaggi e chat in tempo reale per la pianificazione del viaggio',
|
'admin.addons.catalog.collab.description': 'Note, sondaggi e chat in tempo reale per la pianificazione del viaggio',
|
||||||
'admin.addons.catalog.memories.name': 'Foto (Immich)',
|
'admin.addons.catalog.memories.name': 'Foto (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
|
'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol per l\'integrazione di assistenti AI',
|
||||||
'admin.addons.subtitleBefore': 'Abilita o disabilita le funzionalità per personalizzare la tua ',
|
'admin.addons.subtitleBefore': 'Abilita o disabilita le funzionalità per personalizzare la tua ',
|
||||||
'admin.addons.subtitleAfter': ' esperienza.',
|
'admin.addons.subtitleAfter': ' esperienza.',
|
||||||
'admin.addons.enabled': 'Abilitato',
|
'admin.addons.enabled': 'Abilitato',
|
||||||
'admin.addons.disabled': 'Disabilitato',
|
'admin.addons.disabled': 'Disabilitato',
|
||||||
'admin.addons.type.trip': 'Viaggio',
|
'admin.addons.type.trip': 'Viaggio',
|
||||||
'admin.addons.type.global': 'Globale',
|
'admin.addons.type.global': 'Globale',
|
||||||
|
'admin.addons.type.integration': 'Integrazione',
|
||||||
'admin.addons.tripHint': 'Disponibile come scheda all\'interno di ciascun viaggio',
|
'admin.addons.tripHint': 'Disponibile come scheda all\'interno di ciascun viaggio',
|
||||||
'admin.addons.globalHint': 'Disponibile come sezione autonoma nella navigazione principale',
|
'admin.addons.globalHint': 'Disponibile come sezione autonoma nella navigazione principale',
|
||||||
|
'admin.addons.integrationHint': 'Servizi backend e integrazioni API senza pagina dedicata',
|
||||||
'admin.addons.toast.updated': 'Modulo aggiornato',
|
'admin.addons.toast.updated': 'Modulo aggiornato',
|
||||||
'admin.addons.toast.error': 'Impossibile aggiornare il modulo',
|
'admin.addons.toast.error': 'Impossibile aggiornare il modulo',
|
||||||
'admin.addons.noAddons': 'Nessun modulo disponibile',
|
'admin.addons.noAddons': 'Nessun modulo disponibile',
|
||||||
@@ -469,6 +537,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.audit.col.ip': 'IP',
|
'admin.audit.col.ip': 'IP',
|
||||||
'admin.audit.col.details': 'Dettagli',
|
'admin.audit.col.details': 'Dettagli',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'Token MCP',
|
||||||
|
'admin.mcpTokens.title': 'Token MCP',
|
||||||
|
'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti',
|
||||||
|
'admin.mcpTokens.owner': 'Proprietario',
|
||||||
|
'admin.mcpTokens.tokenName': 'Nome token',
|
||||||
|
'admin.mcpTokens.created': 'Creato',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Ultimo utilizzo',
|
||||||
|
'admin.mcpTokens.never': 'Mai',
|
||||||
|
'admin.mcpTokens.empty': 'Non sono ancora stati creati token MCP',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Elimina token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Questo token verrà revocato immediatamente. L\'utente perderà l\'accesso MCP tramite questo token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token eliminato',
|
||||||
|
'admin.mcpTokens.deleteError': 'Impossibile eliminare il token',
|
||||||
|
'admin.mcpTokens.loadError': 'Impossibile caricare i token',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
'admin.github.title': 'Cronologia rilasci',
|
'admin.github.title': 'Cronologia rilasci',
|
||||||
@@ -608,7 +692,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.statsTab': 'Statistiche',
|
'atlas.statsTab': 'Statistiche',
|
||||||
'atlas.bucketTab': 'Lista desideri',
|
'atlas.bucketTab': 'Lista desideri',
|
||||||
'atlas.addBucket': 'Aggiungi alla lista desideri',
|
'atlas.addBucket': 'Aggiungi alla lista desideri',
|
||||||
'atlas.bucketNamePlaceholder': 'Luogo o destinazione...',
|
|
||||||
'atlas.bucketNotesPlaceholder': 'Note (opzionale)',
|
'atlas.bucketNotesPlaceholder': 'Note (opzionale)',
|
||||||
'atlas.bucketEmpty': 'La tua lista desideri è vuota',
|
'atlas.bucketEmpty': 'La tua lista desideri è vuota',
|
||||||
'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare',
|
'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare',
|
||||||
@@ -641,6 +724,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.tripPlural': 'Viaggi',
|
'atlas.tripPlural': 'Viaggi',
|
||||||
'atlas.placeVisited': 'Luogo visitato',
|
'atlas.placeVisited': 'Luogo visitato',
|
||||||
'atlas.placesVisited': 'Luoghi visitati',
|
'atlas.placesVisited': 'Luoghi visitati',
|
||||||
|
'atlas.searchCountry': 'Cerca un paese...',
|
||||||
|
|
||||||
// Trip Planner
|
// Trip Planner
|
||||||
'trip.tabs.plan': 'Programma',
|
'trip.tabs.plan': 'Programma',
|
||||||
@@ -663,6 +747,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
||||||
'trip.toast.deleted': 'Eliminato',
|
'trip.toast.deleted': 'Eliminato',
|
||||||
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
||||||
|
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
|
||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno',
|
'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno',
|
||||||
@@ -697,10 +782,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Aggiungi Luogo/Attività',
|
'places.addPlace': 'Aggiungi Luogo/Attività',
|
||||||
'places.importGpx': 'Importa GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||||
'places.gpxError': 'Importazione GPX non riuscita',
|
'places.gpxError': 'Importazione GPX non riuscita',
|
||||||
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
|
||||||
|
'places.googleListImported': '{count} luoghi importati da "{list}"',
|
||||||
|
'places.googleListError': 'Importazione lista Google Maps non riuscita',
|
||||||
|
'places.viewDetails': 'Visualizza dettagli',
|
||||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||||
'places.all': 'Tutti',
|
'places.all': 'Tutti',
|
||||||
'places.unplanned': 'Non pianificati',
|
'places.unplanned': 'Non pianificati',
|
||||||
@@ -756,6 +846,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'inspector.addRes': 'Prenotazione',
|
'inspector.addRes': 'Prenotazione',
|
||||||
'inspector.editRes': 'Modifica prenotazione',
|
'inspector.editRes': 'Modifica prenotazione',
|
||||||
'inspector.participants': 'Partecipanti',
|
'inspector.participants': 'Partecipanti',
|
||||||
|
'inspector.trackStats': 'Dati del percorso',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Prenotazioni',
|
'reservations.title': 'Prenotazioni',
|
||||||
@@ -838,6 +929,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'Esporta CSV',
|
||||||
'budget.emptyTitle': 'Ancora nessun budget creato',
|
'budget.emptyTitle': 'Ancora nessun budget creato',
|
||||||
'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio',
|
'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio',
|
||||||
'budget.emptyPlaceholder': 'Inserisci nome categoria...',
|
'budget.emptyPlaceholder': 'Inserisci nome categoria...',
|
||||||
@@ -852,6 +944,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'budget.table.perDay': 'Per giorno',
|
'budget.table.perDay': 'Per giorno',
|
||||||
'budget.table.perPersonDay': 'P. p / gio.',
|
'budget.table.perPersonDay': 'P. p / gio.',
|
||||||
'budget.table.note': 'Nota',
|
'budget.table.note': 'Nota',
|
||||||
|
'budget.table.date': 'Data',
|
||||||
'budget.newEntry': 'Nuova voce',
|
'budget.newEntry': 'Nuova voce',
|
||||||
'budget.defaultEntry': 'Nuova voce',
|
'budget.defaultEntry': 'Nuova voce',
|
||||||
'budget.defaultCategory': 'Nuova categoria',
|
'budget.defaultCategory': 'Nuova categoria',
|
||||||
@@ -1245,12 +1338,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichUrl': 'URL Server Immich',
|
'memories.immichUrl': 'URL Server Immich',
|
||||||
'memories.immichApiKey': 'Chiave API',
|
'memories.immichApiKey': 'Chiave API',
|
||||||
'memories.testConnection': 'Test connessione',
|
'memories.testConnection': 'Test connessione',
|
||||||
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
'memories.connected': 'Connesso',
|
'memories.connected': 'Connesso',
|
||||||
'memories.disconnected': 'Non connesso',
|
'memories.disconnected': 'Non connesso',
|
||||||
'memories.connectionSuccess': 'Connesso a Immich',
|
'memories.connectionSuccess': 'Connesso a Immich',
|
||||||
'memories.connectionError': 'Impossibile connettersi a Immich',
|
'memories.connectionError': 'Impossibile connettersi a Immich',
|
||||||
'memories.saved': 'Impostazioni Immich salvate',
|
'memories.saved': 'Impostazioni Immich salvate',
|
||||||
'memories.addPhotos': 'Aggiungi foto',
|
'memories.addPhotos': 'Aggiungi foto',
|
||||||
|
'memories.linkAlbum': 'Collega album',
|
||||||
|
'memories.selectAlbum': 'Seleziona album Immich',
|
||||||
|
'memories.noAlbums': 'Nessun album trovato',
|
||||||
|
'memories.syncAlbum': 'Sincronizza album',
|
||||||
|
'memories.unlinkAlbum': 'Scollega',
|
||||||
|
'memories.photos': 'foto',
|
||||||
'memories.selectPhotos': 'Seleziona foto da Immich',
|
'memories.selectPhotos': 'Seleziona foto da Immich',
|
||||||
'memories.selectHint': 'Tocca le foto per selezionarle.',
|
'memories.selectHint': 'Tocca le foto per selezionarle.',
|
||||||
'memories.selected': 'selezionate',
|
'memories.selected': 'selezionate',
|
||||||
@@ -1285,6 +1385,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.chat.today': 'Oggi',
|
'collab.chat.today': 'Oggi',
|
||||||
'collab.chat.yesterday': 'Ieri',
|
'collab.chat.yesterday': 'Ieri',
|
||||||
'collab.chat.deletedMessage': 'ha eliminato un messaggio',
|
'collab.chat.deletedMessage': 'ha eliminato un messaggio',
|
||||||
|
'collab.chat.reply': 'Rispondi',
|
||||||
'collab.chat.loadMore': 'Carica messaggi precedenti',
|
'collab.chat.loadMore': 'Carica messaggi precedenti',
|
||||||
'collab.chat.justNow': 'ora',
|
'collab.chat.justNow': 'ora',
|
||||||
'collab.chat.minutesAgo': '{n}m fa',
|
'collab.chat.minutesAgo': '{n}m fa',
|
||||||
@@ -1335,6 +1436,55 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'collab.polls.options': 'Opzioni',
|
'collab.polls.options': 'Opzioni',
|
||||||
'collab.polls.delete': 'Elimina',
|
'collab.polls.delete': 'Elimina',
|
||||||
'collab.polls.closedSection': 'Chiusi',
|
'collab.polls.closedSection': 'Chiusi',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permessi',
|
||||||
|
'perm.title': 'Impostazioni dei permessi',
|
||||||
|
'perm.subtitle': 'Controlla chi può eseguire azioni nell\'applicazione',
|
||||||
|
'perm.saved': 'Impostazioni dei permessi salvate',
|
||||||
|
'perm.resetDefaults': 'Ripristina predefiniti',
|
||||||
|
'perm.customized': 'personalizzato',
|
||||||
|
'perm.level.admin': 'Solo amministratore',
|
||||||
|
'perm.level.tripOwner': 'Proprietario del viaggio',
|
||||||
|
'perm.level.tripMember': 'Membri del viaggio',
|
||||||
|
'perm.level.everybody': 'Tutti',
|
||||||
|
'perm.cat.trip': 'Gestione viaggi',
|
||||||
|
'perm.cat.members': 'Gestione membri',
|
||||||
|
'perm.cat.files': 'File',
|
||||||
|
'perm.cat.content': 'Contenuti e programma',
|
||||||
|
'perm.cat.extras': 'Budget, bagagli e collaborazione',
|
||||||
|
'perm.action.trip_create': 'Creare viaggi',
|
||||||
|
'perm.action.trip_edit': 'Modificare dettagli del viaggio',
|
||||||
|
'perm.action.trip_delete': 'Eliminare viaggi',
|
||||||
|
'perm.action.trip_archive': 'Archiviare / dearchiviare viaggi',
|
||||||
|
'perm.action.trip_cover_upload': 'Caricare immagine di copertina',
|
||||||
|
'perm.action.member_manage': 'Aggiungere / rimuovere membri',
|
||||||
|
'perm.action.file_upload': 'Caricare file',
|
||||||
|
'perm.action.file_edit': 'Modificare metadati dei file',
|
||||||
|
'perm.action.file_delete': 'Eliminare file',
|
||||||
|
'perm.action.place_edit': 'Aggiungere / modificare / eliminare luoghi',
|
||||||
|
'perm.action.day_edit': 'Modificare giorni, note e assegnazioni',
|
||||||
|
'perm.action.reservation_edit': 'Gestire prenotazioni',
|
||||||
|
'perm.action.budget_edit': 'Gestire budget',
|
||||||
|
'perm.action.packing_edit': 'Gestire liste bagagli',
|
||||||
|
'perm.action.collab_edit': 'Collaborazione (note, sondaggi, chat)',
|
||||||
|
'perm.action.share_manage': 'Gestire link di condivisione',
|
||||||
|
'perm.actionHint.trip_create': 'Chi può creare nuovi viaggi',
|
||||||
|
'perm.actionHint.trip_edit': 'Chi può modificare nome, date, descrizione e valuta del viaggio',
|
||||||
|
'perm.actionHint.trip_delete': 'Chi può eliminare definitivamente un viaggio',
|
||||||
|
'perm.actionHint.trip_archive': 'Chi può archiviare o dearchiviare un viaggio',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Chi può caricare o modificare l\'immagine di copertina',
|
||||||
|
'perm.actionHint.member_manage': 'Chi può invitare o rimuovere membri del viaggio',
|
||||||
|
'perm.actionHint.file_upload': 'Chi può caricare file in un viaggio',
|
||||||
|
'perm.actionHint.file_edit': 'Chi può modificare descrizioni e link dei file',
|
||||||
|
'perm.actionHint.file_delete': 'Chi può spostare file nel cestino o eliminarli definitivamente',
|
||||||
|
'perm.actionHint.place_edit': 'Chi può aggiungere, modificare o eliminare luoghi',
|
||||||
|
'perm.actionHint.day_edit': 'Chi può modificare giorni, note dei giorni e assegnazioni dei luoghi',
|
||||||
|
'perm.actionHint.reservation_edit': 'Chi può creare, modificare o eliminare prenotazioni',
|
||||||
|
'perm.actionHint.budget_edit': 'Chi può creare, modificare o eliminare voci di budget',
|
||||||
|
'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse',
|
||||||
|
'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi',
|
||||||
|
'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default it
|
export default it
|
||||||
@@ -6,6 +6,7 @@ const nl: Record<string, string> = {
|
|||||||
'common.edit': 'Bewerken',
|
'common.edit': 'Bewerken',
|
||||||
'common.add': 'Toevoegen',
|
'common.add': 'Toevoegen',
|
||||||
'common.loading': 'Laden...',
|
'common.loading': 'Laden...',
|
||||||
|
'common.import': 'Importeren',
|
||||||
'common.error': 'Fout',
|
'common.error': 'Fout',
|
||||||
'common.back': 'Terug',
|
'common.back': 'Terug',
|
||||||
'common.all': 'Alles',
|
'common.all': 'Alles',
|
||||||
@@ -25,6 +26,14 @@ const nl: Record<string, string> = {
|
|||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Wachtwoord',
|
'common.password': 'Wachtwoord',
|
||||||
'common.saving': 'Opslaan...',
|
'common.saving': 'Opslaan...',
|
||||||
|
'common.saved': 'Opgeslagen',
|
||||||
|
'trips.reminder': 'Herinnering',
|
||||||
|
'trips.reminderNone': 'Geen',
|
||||||
|
'trips.reminderDay': 'dag',
|
||||||
|
'trips.reminderDays': 'dagen',
|
||||||
|
'trips.reminderCustom': 'Aangepast',
|
||||||
|
'trips.reminderDaysBefore': 'dagen voor vertrek',
|
||||||
|
'trips.reminderDisabledHint': 'Reisherinneringen zijn uitgeschakeld. Schakel ze in via Admin > Instellingen > Meldingen.',
|
||||||
'common.update': 'Bijwerken',
|
'common.update': 'Bijwerken',
|
||||||
'common.change': 'Wijzigen',
|
'common.change': 'Wijzigen',
|
||||||
'common.uploading': 'Uploaden…',
|
'common.uploading': 'Uploaden…',
|
||||||
@@ -149,9 +158,26 @@ const nl: Record<string, string> = {
|
|||||||
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
|
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
|
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
|
||||||
'settings.notifyWebhook': 'Webhook-meldingen',
|
'settings.notifyWebhook': 'Webhook-meldingen',
|
||||||
|
'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.',
|
||||||
|
'settings.notificationsActive': 'Actief kanaal',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Meldingsgebeurtenissen worden geconfigureerd door je beheerder.',
|
||||||
|
'admin.notifications.title': 'Meldingen',
|
||||||
|
'admin.notifications.hint': 'Kies een meldingskanaal. Er kan er slechts één tegelijk actief zijn.',
|
||||||
|
'admin.notifications.none': 'Uitgeschakeld',
|
||||||
|
'admin.notifications.email': 'E-mail (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Meldingsgebeurtenissen',
|
||||||
|
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
|
||||||
|
'admin.notifications.configureFirst': 'Configureer eerst de SMTP- of webhook-instellingen hieronder en schakel dan de events in.',
|
||||||
|
'admin.notifications.save': 'Meldingsinstellingen opslaan',
|
||||||
|
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
|
||||||
|
'admin.notifications.testWebhook': 'Testwebhook verzenden',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Testwebhook succesvol verzonden',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Testwebhook mislukt',
|
||||||
'admin.smtp.title': 'E-mail en meldingen',
|
'admin.smtp.title': 'E-mail en meldingen',
|
||||||
'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
|
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
|
||||||
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
||||||
|
'admin.webhook.hint': 'Meldingen verzenden naar een externe webhook (Discord, Slack, enz.).',
|
||||||
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
||||||
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
||||||
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
||||||
@@ -185,6 +211,31 @@ const nl: Record<string, string> = {
|
|||||||
'share.permCollab': 'Chat',
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'Aan',
|
'settings.on': 'Aan',
|
||||||
'settings.off': 'Uit',
|
'settings.off': 'Uit',
|
||||||
|
'settings.mcp.title': 'MCP-configuratie',
|
||||||
|
'settings.mcp.endpoint': 'MCP-eindpunt',
|
||||||
|
'settings.mcp.clientConfig': 'Clientconfiguratie',
|
||||||
|
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
|
||||||
|
'settings.mcp.copy': 'Kopiëren',
|
||||||
|
'settings.mcp.copied': 'Gekopieerd!',
|
||||||
|
'settings.mcp.apiTokens': 'API-tokens',
|
||||||
|
'settings.mcp.createToken': 'Nieuw token aanmaken',
|
||||||
|
'settings.mcp.noTokens': 'Nog geen tokens. Maak er een aan om MCP-clients te verbinden.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Aangemaakt',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Gebruikt',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Token verwijderen',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Dit token werkt onmiddellijk niet meer. Elke MCP-client die het gebruikt verliest de toegang.',
|
||||||
|
'settings.mcp.modal.createTitle': 'API-token aanmaken',
|
||||||
|
'settings.mcp.modal.tokenName': 'Tokennaam',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'bijv. Claude Desktop, Werklaptop',
|
||||||
|
'settings.mcp.modal.creating': 'Aanmaken…',
|
||||||
|
'settings.mcp.modal.create': 'Token aanmaken',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token aangemaakt',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Dit token wordt slechts één keer getoond. Kopieer en bewaar het nu — het kan niet worden hersteld.',
|
||||||
|
'settings.mcp.modal.done': 'Klaar',
|
||||||
|
'settings.mcp.toast.created': 'Token aangemaakt',
|
||||||
|
'settings.mcp.toast.createError': 'Token aanmaken mislukt',
|
||||||
|
'settings.mcp.toast.deleted': 'Token verwijderd',
|
||||||
|
'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.username': 'Gebruikersnaam',
|
'settings.username': 'Gebruikersnaam',
|
||||||
'settings.email': 'E-mail',
|
'settings.email': 'E-mail',
|
||||||
@@ -192,6 +243,7 @@ const nl: Record<string, string> = {
|
|||||||
'settings.roleAdmin': 'Beheerder',
|
'settings.roleAdmin': 'Beheerder',
|
||||||
'settings.oidcLinked': 'Gekoppeld met',
|
'settings.oidcLinked': 'Gekoppeld met',
|
||||||
'settings.changePassword': 'Wachtwoord wijzigen',
|
'settings.changePassword': 'Wachtwoord wijzigen',
|
||||||
|
'settings.mustChangePassword': 'U moet uw wachtwoord wijzigen voordat u kunt doorgaan. Stel hieronder een nieuw wachtwoord in.',
|
||||||
'settings.currentPassword': 'Huidig wachtwoord',
|
'settings.currentPassword': 'Huidig wachtwoord',
|
||||||
'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht',
|
'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht',
|
||||||
'settings.newPassword': 'Nieuw wachtwoord',
|
'settings.newPassword': 'Nieuw wachtwoord',
|
||||||
@@ -200,7 +252,7 @@ const nl: Record<string, string> = {
|
|||||||
'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in',
|
'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in',
|
||||||
'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||||
'settings.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
'settings.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
||||||
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten',
|
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten',
|
||||||
'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd',
|
'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd',
|
||||||
'settings.deleteAccount': 'Account verwijderen',
|
'settings.deleteAccount': 'Account verwijderen',
|
||||||
'settings.deleteAccountTitle': 'Account verwijderen?',
|
'settings.deleteAccountTitle': 'Account verwijderen?',
|
||||||
@@ -212,6 +264,14 @@ const nl: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Profiel opslaan',
|
'settings.saveProfile': 'Profiel opslaan',
|
||||||
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
|
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
|
||||||
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Je beheerder vereist tweestapsverificatie. Stel hieronder een authenticator-app in voordat je verdergaat.',
|
||||||
|
'settings.mfa.backupTitle': 'Back-upcodes',
|
||||||
|
'settings.mfa.backupDescription': 'Gebruik deze eenmalige codes als je geen toegang meer hebt tot je authenticator-app.',
|
||||||
|
'settings.mfa.backupWarning': 'Sla deze codes nu op. Elke code kan maar een keer worden gebruikt.',
|
||||||
|
'settings.mfa.backupCopy': 'Codes kopiëren',
|
||||||
|
'settings.mfa.backupDownload': 'TXT downloaden',
|
||||||
|
'settings.mfa.backupPrint': 'Afdrukken / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Back-upcodes gekopieerd',
|
||||||
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
|
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
|
||||||
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
|
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
|
||||||
'settings.mfa.setup': 'Authenticator instellen',
|
'settings.mfa.setup': 'Authenticator instellen',
|
||||||
@@ -263,6 +323,8 @@ const nl: Record<string, string> = {
|
|||||||
'login.signIn': 'Inloggen',
|
'login.signIn': 'Inloggen',
|
||||||
'login.createAdmin': 'Beheerdersaccount aanmaken',
|
'login.createAdmin': 'Beheerdersaccount aanmaken',
|
||||||
'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.',
|
'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.',
|
||||||
|
'login.setNewPassword': 'Nieuw wachtwoord instellen',
|
||||||
|
'login.setNewPasswordHint': 'U moet uw wachtwoord wijzigen voordat u verder kunt gaan.',
|
||||||
'login.createAccount': 'Account aanmaken',
|
'login.createAccount': 'Account aanmaken',
|
||||||
'login.createAccountHint': 'Registreer een nieuw account.',
|
'login.createAccountHint': 'Registreer een nieuw account.',
|
||||||
'login.creating': 'Aanmaken…',
|
'login.creating': 'Aanmaken…',
|
||||||
@@ -289,7 +351,7 @@ const nl: Record<string, string> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
'register.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
||||||
'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten',
|
'register.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||||
'register.failed': 'Registratie mislukt',
|
'register.failed': 'Registratie mislukt',
|
||||||
'register.getStarted': 'Aan de slag',
|
'register.getStarted': 'Aan de slag',
|
||||||
'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.',
|
'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.',
|
||||||
@@ -365,6 +427,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Instellingen',
|
'admin.tabs.settings': 'Instellingen',
|
||||||
'admin.allowRegistration': 'Registratie toestaan',
|
'admin.allowRegistration': 'Registratie toestaan',
|
||||||
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
|
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
|
||||||
|
'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
|
||||||
|
'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
|
||||||
'admin.apiKeys': 'API-sleutels',
|
'admin.apiKeys': 'API-sleutels',
|
||||||
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
|
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
|
||||||
'admin.mapsKey': 'Google Maps API-sleutel',
|
'admin.mapsKey': 'Google Maps API-sleutel',
|
||||||
@@ -418,8 +482,10 @@ const nl: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Add-ons',
|
'admin.tabs.addons': 'Add-ons',
|
||||||
'admin.addons.title': 'Add-ons',
|
'admin.addons.title': 'Add-ons',
|
||||||
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
|
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
|
||||||
'admin.addons.catalog.memories.name': 'Herinneringen',
|
'admin.addons.catalog.memories.name': 'Foto\'s (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Gedeelde fotoalbums voor elke reis',
|
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
|
||||||
'admin.addons.catalog.packing.name': 'Inpakken',
|
'admin.addons.catalog.packing.name': 'Inpakken',
|
||||||
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
|
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
|
||||||
'admin.addons.catalog.budget.name': 'Budget',
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
@@ -438,8 +504,10 @@ const nl: Record<string, string> = {
|
|||||||
'admin.addons.disabled': 'Uitgeschakeld',
|
'admin.addons.disabled': 'Uitgeschakeld',
|
||||||
'admin.addons.type.trip': 'Reis',
|
'admin.addons.type.trip': 'Reis',
|
||||||
'admin.addons.type.global': 'Globaal',
|
'admin.addons.type.global': 'Globaal',
|
||||||
|
'admin.addons.type.integration': 'Integratie',
|
||||||
'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis',
|
'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis',
|
||||||
'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie',
|
'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie',
|
||||||
|
'admin.addons.integrationHint': 'Backenddiensten en API-integraties zonder eigen pagina',
|
||||||
'admin.addons.toast.updated': 'Add-on bijgewerkt',
|
'admin.addons.toast.updated': 'Add-on bijgewerkt',
|
||||||
'admin.addons.toast.error': 'Add-on bijwerken mislukt',
|
'admin.addons.toast.error': 'Add-on bijwerken mislukt',
|
||||||
'admin.addons.noAddons': 'Geen add-ons beschikbaar',
|
'admin.addons.noAddons': 'Geen add-ons beschikbaar',
|
||||||
@@ -455,6 +523,22 @@ const nl: Record<string, string> = {
|
|||||||
'admin.weather.requestsDesc': 'Gratis, geen API-sleutel vereist',
|
'admin.weather.requestsDesc': 'Gratis, geen API-sleutel vereist',
|
||||||
'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.',
|
'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP-tokens',
|
||||||
|
'admin.mcpTokens.title': 'MCP-tokens',
|
||||||
|
'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren',
|
||||||
|
'admin.mcpTokens.owner': 'Eigenaar',
|
||||||
|
'admin.mcpTokens.tokenName': 'Tokennaam',
|
||||||
|
'admin.mcpTokens.created': 'Aangemaakt',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Laatst gebruikt',
|
||||||
|
'admin.mcpTokens.never': 'Nooit',
|
||||||
|
'admin.mcpTokens.empty': 'Er zijn nog geen MCP-tokens aangemaakt',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Token verwijderen',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Dit token wordt onmiddellijk ingetrokken. De gebruiker verliest MCP-toegang via dit token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token verwijderd',
|
||||||
|
'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd',
|
||||||
|
'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
@@ -636,9 +720,8 @@ const nl: Record<string, string> = {
|
|||||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
||||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||||
'atlas.addPoi': 'Plaats toevoegen',
|
'atlas.addPoi': 'Plaats toevoegen',
|
||||||
'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)',
|
'atlas.searchCountry': 'Zoek een land...',
|
||||||
'atlas.month': 'Maand',
|
'atlas.month': 'Maand',
|
||||||
'atlas.year': 'Jaar',
|
|
||||||
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
|
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
|
||||||
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
|
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
|
||||||
|
|
||||||
@@ -651,6 +734,7 @@ const nl: Record<string, string> = {
|
|||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Bestanden',
|
'trip.tabs.files': 'Bestanden',
|
||||||
'trip.loading': 'Reis laden...',
|
'trip.loading': 'Reis laden...',
|
||||||
|
'trip.loadingPhotos': 'Plaatsfoto laden...',
|
||||||
'trip.mobilePlan': 'Plan',
|
'trip.mobilePlan': 'Plan',
|
||||||
'trip.mobilePlaces': 'Plaatsen',
|
'trip.mobilePlaces': 'Plaatsen',
|
||||||
'trip.toast.placeUpdated': 'Plaats bijgewerkt',
|
'trip.toast.placeUpdated': 'Plaats bijgewerkt',
|
||||||
@@ -697,9 +781,14 @@ const nl: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Plaats/activiteit toevoegen',
|
'places.addPlace': 'Plaats/activiteit toevoegen',
|
||||||
'places.importGpx': 'GPX importeren',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||||
'places.gpxError': 'GPX-import mislukt',
|
'places.gpxError': 'GPX-import mislukt',
|
||||||
|
'places.importGoogleList': 'Google Lijst',
|
||||||
|
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
|
||||||
|
'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
|
||||||
|
'places.googleListError': 'Google Maps lijst importeren mislukt',
|
||||||
|
'places.viewDetails': 'Details bekijken',
|
||||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||||
'places.all': 'Alle',
|
'places.all': 'Alle',
|
||||||
@@ -756,6 +845,7 @@ const nl: Record<string, string> = {
|
|||||||
'inspector.addRes': 'Reservering',
|
'inspector.addRes': 'Reservering',
|
||||||
'inspector.editRes': 'Reservering bewerken',
|
'inspector.editRes': 'Reservering bewerken',
|
||||||
'inspector.participants': 'Deelnemers',
|
'inspector.participants': 'Deelnemers',
|
||||||
|
'inspector.trackStats': 'Routegegevens',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Boekingen',
|
'reservations.title': 'Boekingen',
|
||||||
@@ -838,6 +928,7 @@ const nl: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'CSV exporteren',
|
||||||
'budget.emptyTitle': 'Nog geen budget aangemaakt',
|
'budget.emptyTitle': 'Nog geen budget aangemaakt',
|
||||||
'budget.emptyText': 'Maak categorieën en invoeren aan om je reisbudget te plannen',
|
'budget.emptyText': 'Maak categorieën en invoeren aan om je reisbudget te plannen',
|
||||||
'budget.emptyPlaceholder': 'Categorienaam invoeren...',
|
'budget.emptyPlaceholder': 'Categorienaam invoeren...',
|
||||||
@@ -852,6 +943,7 @@ const nl: Record<string, string> = {
|
|||||||
'budget.table.perDay': 'Per dag',
|
'budget.table.perDay': 'Per dag',
|
||||||
'budget.table.perPersonDay': 'P. p. / dag',
|
'budget.table.perPersonDay': 'P. p. / dag',
|
||||||
'budget.table.note': 'Notitie',
|
'budget.table.note': 'Notitie',
|
||||||
|
'budget.table.date': 'Datum',
|
||||||
'budget.newEntry': 'Nieuwe invoer',
|
'budget.newEntry': 'Nieuwe invoer',
|
||||||
'budget.defaultEntry': 'Nieuwe invoer',
|
'budget.defaultEntry': 'Nieuwe invoer',
|
||||||
'budget.defaultCategory': 'Nieuwe categorie',
|
'budget.defaultCategory': 'Nieuwe categorie',
|
||||||
@@ -1245,6 +1337,7 @@ const nl: Record<string, string> = {
|
|||||||
'memories.immichUrl': 'Immich Server URL',
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
'memories.immichApiKey': 'API-sleutel',
|
'memories.immichApiKey': 'API-sleutel',
|
||||||
'memories.testConnection': 'Verbinding testen',
|
'memories.testConnection': 'Verbinding testen',
|
||||||
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
'memories.connected': 'Verbonden',
|
'memories.connected': 'Verbonden',
|
||||||
'memories.disconnected': 'Niet verbonden',
|
'memories.disconnected': 'Niet verbonden',
|
||||||
'memories.connectionSuccess': 'Verbonden met Immich',
|
'memories.connectionSuccess': 'Verbonden met Immich',
|
||||||
@@ -1254,6 +1347,12 @@ const nl: Record<string, string> = {
|
|||||||
'memories.newest': 'Nieuwste eerst',
|
'memories.newest': 'Nieuwste eerst',
|
||||||
'memories.allLocations': 'Alle locaties',
|
'memories.allLocations': 'Alle locaties',
|
||||||
'memories.addPhotos': 'Foto\'s toevoegen',
|
'memories.addPhotos': 'Foto\'s toevoegen',
|
||||||
|
'memories.linkAlbum': 'Album koppelen',
|
||||||
|
'memories.selectAlbum': 'Immich-album selecteren',
|
||||||
|
'memories.noAlbums': 'Geen albums gevonden',
|
||||||
|
'memories.syncAlbum': 'Album synchroniseren',
|
||||||
|
'memories.unlinkAlbum': 'Ontkoppelen',
|
||||||
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
|
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
|
||||||
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
|
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
|
||||||
'memories.selected': 'geselecteerd',
|
'memories.selected': 'geselecteerd',
|
||||||
@@ -1285,6 +1384,7 @@ const nl: Record<string, string> = {
|
|||||||
'collab.chat.today': 'Vandaag',
|
'collab.chat.today': 'Vandaag',
|
||||||
'collab.chat.yesterday': 'Gisteren',
|
'collab.chat.yesterday': 'Gisteren',
|
||||||
'collab.chat.deletedMessage': 'heeft een bericht verwijderd',
|
'collab.chat.deletedMessage': 'heeft een bericht verwijderd',
|
||||||
|
'collab.chat.reply': 'Beantwoorden',
|
||||||
'collab.chat.loadMore': 'Oudere berichten laden',
|
'collab.chat.loadMore': 'Oudere berichten laden',
|
||||||
'collab.chat.justNow': 'zojuist',
|
'collab.chat.justNow': 'zojuist',
|
||||||
'collab.chat.minutesAgo': '{n} min. geleden',
|
'collab.chat.minutesAgo': '{n} min. geleden',
|
||||||
@@ -1335,6 +1435,55 @@ const nl: Record<string, string> = {
|
|||||||
'collab.polls.options': 'Opties',
|
'collab.polls.options': 'Opties',
|
||||||
'collab.polls.delete': 'Verwijderen',
|
'collab.polls.delete': 'Verwijderen',
|
||||||
'collab.polls.closedSection': 'Gesloten',
|
'collab.polls.closedSection': 'Gesloten',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Rechten',
|
||||||
|
'perm.title': 'Rechtinstellingen',
|
||||||
|
'perm.subtitle': 'Bepaal wie welke acties mag uitvoeren in de applicatie',
|
||||||
|
'perm.saved': 'Rechtinstellingen opgeslagen',
|
||||||
|
'perm.resetDefaults': 'Standaardwaarden herstellen',
|
||||||
|
'perm.customized': 'aangepast',
|
||||||
|
'perm.level.admin': 'Alleen beheerder',
|
||||||
|
'perm.level.tripOwner': 'Reiseigenaar',
|
||||||
|
'perm.level.tripMember': 'Reisleden',
|
||||||
|
'perm.level.everybody': 'Iedereen',
|
||||||
|
'perm.cat.trip': 'Reisbeheer',
|
||||||
|
'perm.cat.members': 'Ledenbeheer',
|
||||||
|
'perm.cat.files': 'Bestanden',
|
||||||
|
'perm.cat.content': 'Inhoud & planning',
|
||||||
|
'perm.cat.extras': 'Budget, paklijsten & samenwerking',
|
||||||
|
'perm.action.trip_create': 'Reizen aanmaken',
|
||||||
|
'perm.action.trip_edit': 'Reisdetails bewerken',
|
||||||
|
'perm.action.trip_delete': 'Reizen verwijderen',
|
||||||
|
'perm.action.trip_archive': 'Reizen archiveren / dearchiveren',
|
||||||
|
'perm.action.trip_cover_upload': 'Omslagfoto uploaden',
|
||||||
|
'perm.action.member_manage': 'Leden toevoegen / verwijderen',
|
||||||
|
'perm.action.file_upload': 'Bestanden uploaden',
|
||||||
|
'perm.action.file_edit': 'Bestandsmetadata bewerken',
|
||||||
|
'perm.action.file_delete': 'Bestanden verwijderen',
|
||||||
|
'perm.action.place_edit': 'Plaatsen toevoegen / bewerken / verwijderen',
|
||||||
|
'perm.action.day_edit': 'Dagen, notities & toewijzingen bewerken',
|
||||||
|
'perm.action.reservation_edit': 'Reserveringen beheren',
|
||||||
|
'perm.action.budget_edit': 'Budget beheren',
|
||||||
|
'perm.action.packing_edit': 'Paklijsten beheren',
|
||||||
|
'perm.action.collab_edit': 'Samenwerking (notities, polls, chat)',
|
||||||
|
'perm.action.share_manage': 'Deellinks beheren',
|
||||||
|
'perm.actionHint.trip_create': 'Wie kan nieuwe reizen aanmaken',
|
||||||
|
'perm.actionHint.trip_edit': 'Wie kan reisnaam, data, beschrijving en valuta wijzigen',
|
||||||
|
'perm.actionHint.trip_delete': 'Wie kan een reis permanent verwijderen',
|
||||||
|
'perm.actionHint.trip_archive': 'Wie kan een reis archiveren of dearchiveren',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Wie kan de omslagfoto uploaden of wijzigen',
|
||||||
|
'perm.actionHint.member_manage': 'Wie kan reisleden uitnodigen of verwijderen',
|
||||||
|
'perm.actionHint.file_upload': 'Wie kan bestanden uploaden naar een reis',
|
||||||
|
'perm.actionHint.file_edit': 'Wie kan bestandsbeschrijvingen en links bewerken',
|
||||||
|
'perm.actionHint.file_delete': 'Wie kan bestanden naar de prullenbak verplaatsen of permanent verwijderen',
|
||||||
|
'perm.actionHint.place_edit': 'Wie kan plaatsen toevoegen, bewerken of verwijderen',
|
||||||
|
'perm.actionHint.day_edit': 'Wie kan dagen, dagnotities en plaatstoewijzingen bewerken',
|
||||||
|
'perm.actionHint.reservation_edit': 'Wie kan reserveringen aanmaken, bewerken of verwijderen',
|
||||||
|
'perm.actionHint.budget_edit': 'Wie kan budgetposten aanmaken, bewerken of verwijderen',
|
||||||
|
'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren',
|
||||||
|
'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen',
|
||||||
|
'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nl
|
export default nl
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const ru: Record<string, string> = {
|
|||||||
'common.edit': 'Редактировать',
|
'common.edit': 'Редактировать',
|
||||||
'common.add': 'Добавить',
|
'common.add': 'Добавить',
|
||||||
'common.loading': 'Загрузка...',
|
'common.loading': 'Загрузка...',
|
||||||
|
'common.import': 'Импорт',
|
||||||
'common.error': 'Ошибка',
|
'common.error': 'Ошибка',
|
||||||
'common.back': 'Назад',
|
'common.back': 'Назад',
|
||||||
'common.all': 'Все',
|
'common.all': 'Все',
|
||||||
@@ -25,6 +26,14 @@ const ru: Record<string, string> = {
|
|||||||
'common.email': 'Эл. почта',
|
'common.email': 'Эл. почта',
|
||||||
'common.password': 'Пароль',
|
'common.password': 'Пароль',
|
||||||
'common.saving': 'Сохранение...',
|
'common.saving': 'Сохранение...',
|
||||||
|
'common.saved': 'Сохранено',
|
||||||
|
'trips.reminder': 'Напоминание',
|
||||||
|
'trips.reminderNone': 'Нет',
|
||||||
|
'trips.reminderDay': 'день',
|
||||||
|
'trips.reminderDays': 'дней',
|
||||||
|
'trips.reminderCustom': 'Другое',
|
||||||
|
'trips.reminderDaysBefore': 'дней до отъезда',
|
||||||
|
'trips.reminderDisabledHint': 'Напоминания о поездках отключены. Включите их в Админ > Настройки > Уведомления.',
|
||||||
'common.update': 'Обновить',
|
'common.update': 'Обновить',
|
||||||
'common.change': 'Изменить',
|
'common.change': 'Изменить',
|
||||||
'common.uploading': 'Загрузка…',
|
'common.uploading': 'Загрузка…',
|
||||||
@@ -149,9 +158,26 @@ const ru: Record<string, string> = {
|
|||||||
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Список вещей: назначения',
|
'settings.notifyPackingTagged': 'Список вещей: назначения',
|
||||||
'settings.notifyWebhook': 'Webhook-уведомления',
|
'settings.notifyWebhook': 'Webhook-уведомления',
|
||||||
|
'settings.notificationsDisabled': 'Уведомления не настроены. Попросите администратора включить уведомления по электронной почте или webhook.',
|
||||||
|
'settings.notificationsActive': 'Активный канал',
|
||||||
|
'settings.notificationsManagedByAdmin': 'События уведомлений настраиваются администратором.',
|
||||||
|
'admin.notifications.title': 'Уведомления',
|
||||||
|
'admin.notifications.hint': 'Выберите канал уведомлений. Одновременно может быть активен только один.',
|
||||||
|
'admin.notifications.none': 'Отключено',
|
||||||
|
'admin.notifications.email': 'Эл. почта (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'События уведомлений',
|
||||||
|
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
|
||||||
|
'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
|
||||||
|
'admin.notifications.save': 'Сохранить настройки уведомлений',
|
||||||
|
'admin.notifications.saved': 'Настройки уведомлений сохранены',
|
||||||
|
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Тестовый вебхук успешно отправлен',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Ошибка отправки тестового вебхука',
|
||||||
'admin.smtp.title': 'Почта и уведомления',
|
'admin.smtp.title': 'Почта и уведомления',
|
||||||
'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.',
|
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
|
||||||
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
||||||
|
'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
|
||||||
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
||||||
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
||||||
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
||||||
@@ -185,6 +211,31 @@ const ru: Record<string, string> = {
|
|||||||
'share.permCollab': 'Чат',
|
'share.permCollab': 'Чат',
|
||||||
'settings.on': 'Вкл.',
|
'settings.on': 'Вкл.',
|
||||||
'settings.off': 'Выкл.',
|
'settings.off': 'Выкл.',
|
||||||
|
'settings.mcp.title': 'Настройка MCP',
|
||||||
|
'settings.mcp.endpoint': 'MCP-эндпоинт',
|
||||||
|
'settings.mcp.clientConfig': 'Конфигурация клиента',
|
||||||
|
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
|
||||||
|
'settings.mcp.copy': 'Копировать',
|
||||||
|
'settings.mcp.copied': 'Скопировано!',
|
||||||
|
'settings.mcp.apiTokens': 'API-токены',
|
||||||
|
'settings.mcp.createToken': 'Создать токен',
|
||||||
|
'settings.mcp.noTokens': 'Токенов пока нет. Создайте один для подключения MCP-клиентов.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Создан',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Использован',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Удалить токен',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'Этот токен перестанет работать немедленно. Любой MCP-клиент, использующий его, потеряет доступ.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Создать API-токен',
|
||||||
|
'settings.mcp.modal.tokenName': 'Название токена',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'напр. Claude Desktop, Рабочий ноутбук',
|
||||||
|
'settings.mcp.modal.creating': 'Создание…',
|
||||||
|
'settings.mcp.modal.create': 'Создать токен',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Токен создан',
|
||||||
|
'settings.mcp.modal.createdWarning': 'Этот токен будет показан только один раз. Скопируйте и сохраните его сейчас — восстановить его будет невозможно.',
|
||||||
|
'settings.mcp.modal.done': 'Готово',
|
||||||
|
'settings.mcp.toast.created': 'Токен создан',
|
||||||
|
'settings.mcp.toast.createError': 'Не удалось создать токен',
|
||||||
|
'settings.mcp.toast.deleted': 'Токен удалён',
|
||||||
|
'settings.mcp.toast.deleteError': 'Не удалось удалить токен',
|
||||||
'settings.account': 'Аккаунт',
|
'settings.account': 'Аккаунт',
|
||||||
'settings.username': 'Имя пользователя',
|
'settings.username': 'Имя пользователя',
|
||||||
'settings.email': 'Эл. почта',
|
'settings.email': 'Эл. почта',
|
||||||
@@ -192,6 +243,7 @@ const ru: Record<string, string> = {
|
|||||||
'settings.roleAdmin': 'Администратор',
|
'settings.roleAdmin': 'Администратор',
|
||||||
'settings.oidcLinked': 'Связан с',
|
'settings.oidcLinked': 'Связан с',
|
||||||
'settings.changePassword': 'Изменить пароль',
|
'settings.changePassword': 'Изменить пароль',
|
||||||
|
'settings.mustChangePassword': 'Вы должны сменить пароль перед продолжением. Пожалуйста, установите новый пароль ниже.',
|
||||||
'settings.currentPassword': 'Текущий пароль',
|
'settings.currentPassword': 'Текущий пароль',
|
||||||
'settings.currentPasswordRequired': 'Текущий пароль обязателен',
|
'settings.currentPasswordRequired': 'Текущий пароль обязателен',
|
||||||
'settings.newPassword': 'Новый пароль',
|
'settings.newPassword': 'Новый пароль',
|
||||||
@@ -200,7 +252,7 @@ const ru: Record<string, string> = {
|
|||||||
'settings.passwordRequired': 'Введите текущий и новый пароль',
|
'settings.passwordRequired': 'Введите текущий и новый пароль',
|
||||||
'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
|
'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
|
||||||
'settings.passwordMismatch': 'Пароли не совпадают',
|
'settings.passwordMismatch': 'Пароли не совпадают',
|
||||||
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру',
|
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ',
|
||||||
'settings.passwordChanged': 'Пароль успешно изменён',
|
'settings.passwordChanged': 'Пароль успешно изменён',
|
||||||
'settings.deleteAccount': 'Удалить аккаунт',
|
'settings.deleteAccount': 'Удалить аккаунт',
|
||||||
'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
|
'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
|
||||||
@@ -212,6 +264,14 @@ const ru: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Сохранить профиль',
|
'settings.saveProfile': 'Сохранить профиль',
|
||||||
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
|
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
|
||||||
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
|
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.',
|
||||||
|
'settings.mfa.backupTitle': 'Резервные коды',
|
||||||
|
'settings.mfa.backupDescription': 'Используйте эти одноразовые коды, если потеряете доступ к приложению-аутентификатору.',
|
||||||
|
'settings.mfa.backupWarning': 'Сохраните их сейчас. Каждый код можно использовать только один раз.',
|
||||||
|
'settings.mfa.backupCopy': 'Скопировать коды',
|
||||||
|
'settings.mfa.backupDownload': 'Скачать TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Печать / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Резервные коды скопированы',
|
||||||
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
|
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
|
||||||
'settings.mfa.disabled': '2FA не включена.',
|
'settings.mfa.disabled': '2FA не включена.',
|
||||||
'settings.mfa.setup': 'Настроить аутентификатор',
|
'settings.mfa.setup': 'Настроить аутентификатор',
|
||||||
@@ -263,6 +323,8 @@ const ru: Record<string, string> = {
|
|||||||
'login.signIn': 'Войти',
|
'login.signIn': 'Войти',
|
||||||
'login.createAdmin': 'Создать аккаунт администратора',
|
'login.createAdmin': 'Создать аккаунт администратора',
|
||||||
'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.',
|
'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.',
|
||||||
|
'login.setNewPassword': 'Установить новый пароль',
|
||||||
|
'login.setNewPasswordHint': 'Вы должны сменить пароль, прежде чем продолжить.',
|
||||||
'login.createAccount': 'Создать аккаунт',
|
'login.createAccount': 'Создать аккаунт',
|
||||||
'login.createAccountHint': 'Зарегистрируйте новый аккаунт.',
|
'login.createAccountHint': 'Зарегистрируйте новый аккаунт.',
|
||||||
'login.creating': 'Создание…',
|
'login.creating': 'Создание…',
|
||||||
@@ -289,7 +351,7 @@ const ru: Record<string, string> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Пароли не совпадают',
|
'register.passwordMismatch': 'Пароли не совпадают',
|
||||||
'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов',
|
'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
|
||||||
'register.failed': 'Ошибка регистрации',
|
'register.failed': 'Ошибка регистрации',
|
||||||
'register.getStarted': 'Начать',
|
'register.getStarted': 'Начать',
|
||||||
'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',
|
'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',
|
||||||
@@ -365,6 +427,8 @@ const ru: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Настройки',
|
'admin.tabs.settings': 'Настройки',
|
||||||
'admin.allowRegistration': 'Разрешить регистрацию',
|
'admin.allowRegistration': 'Разрешить регистрацию',
|
||||||
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
||||||
|
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
|
||||||
'admin.apiKeys': 'API-ключи',
|
'admin.apiKeys': 'API-ключи',
|
||||||
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
||||||
'admin.mapsKey': 'API-ключ Google Maps',
|
'admin.mapsKey': 'API-ключ Google Maps',
|
||||||
@@ -418,8 +482,10 @@ const ru: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Дополнения',
|
'admin.tabs.addons': 'Дополнения',
|
||||||
'admin.addons.title': 'Дополнения',
|
'admin.addons.title': 'Дополнения',
|
||||||
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
|
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
|
||||||
'admin.addons.catalog.memories.name': 'Воспоминания',
|
'admin.addons.catalog.memories.name': 'Фото (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Общие фотоальбомы для каждой поездки',
|
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами',
|
||||||
'admin.addons.catalog.packing.name': 'Сборы',
|
'admin.addons.catalog.packing.name': 'Сборы',
|
||||||
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
|
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
|
||||||
'admin.addons.catalog.budget.name': 'Бюджет',
|
'admin.addons.catalog.budget.name': 'Бюджет',
|
||||||
@@ -438,8 +504,10 @@ const ru: Record<string, string> = {
|
|||||||
'admin.addons.disabled': 'Отключено',
|
'admin.addons.disabled': 'Отключено',
|
||||||
'admin.addons.type.trip': 'Поездка',
|
'admin.addons.type.trip': 'Поездка',
|
||||||
'admin.addons.type.global': 'Глобально',
|
'admin.addons.type.global': 'Глобально',
|
||||||
|
'admin.addons.type.integration': 'Интеграция',
|
||||||
'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки',
|
'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки',
|
||||||
'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации',
|
'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации',
|
||||||
|
'admin.addons.integrationHint': 'Фоновые сервисы и API-интеграции без отдельной страницы',
|
||||||
'admin.addons.toast.updated': 'Дополнение обновлено',
|
'admin.addons.toast.updated': 'Дополнение обновлено',
|
||||||
'admin.addons.toast.error': 'Не удалось обновить дополнение',
|
'admin.addons.toast.error': 'Не удалось обновить дополнение',
|
||||||
'admin.addons.noAddons': 'Нет доступных дополнений',
|
'admin.addons.noAddons': 'Нет доступных дополнений',
|
||||||
@@ -455,6 +523,22 @@ const ru: Record<string, string> = {
|
|||||||
'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется',
|
'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется',
|
||||||
'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
|
'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP-токены',
|
||||||
|
'admin.mcpTokens.title': 'MCP-токены',
|
||||||
|
'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей',
|
||||||
|
'admin.mcpTokens.owner': 'Владелец',
|
||||||
|
'admin.mcpTokens.tokenName': 'Название токена',
|
||||||
|
'admin.mcpTokens.created': 'Создан',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Последнее использование',
|
||||||
|
'admin.mcpTokens.never': 'Никогда',
|
||||||
|
'admin.mcpTokens.empty': 'MCP-токены ещё не созданы',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Удалить токен',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'Токен будет немедленно отозван. Пользователь потеряет доступ к MCP через этот токен.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Токен удалён',
|
||||||
|
'admin.mcpTokens.deleteError': 'Не удалось удалить токен',
|
||||||
|
'admin.mcpTokens.loadError': 'Не удалось загрузить токены',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
@@ -636,9 +720,8 @@ const ru: Record<string, string> = {
|
|||||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||||
'atlas.addToBucket': 'В список желаний',
|
'atlas.addToBucket': 'В список желаний',
|
||||||
'atlas.addPoi': 'Добавить место',
|
'atlas.addPoi': 'Добавить место',
|
||||||
'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)',
|
'atlas.searchCountry': 'Поиск страны...',
|
||||||
'atlas.month': 'Месяц',
|
'atlas.month': 'Месяц',
|
||||||
'atlas.year': 'Год',
|
|
||||||
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
||||||
'atlas.bucketWhen': 'Когда вы планируете поехать?',
|
'atlas.bucketWhen': 'Когда вы планируете поехать?',
|
||||||
|
|
||||||
@@ -651,6 +734,7 @@ const ru: Record<string, string> = {
|
|||||||
'trip.tabs.budget': 'Бюджет',
|
'trip.tabs.budget': 'Бюджет',
|
||||||
'trip.tabs.files': 'Файлы',
|
'trip.tabs.files': 'Файлы',
|
||||||
'trip.loading': 'Загрузка поездки...',
|
'trip.loading': 'Загрузка поездки...',
|
||||||
|
'trip.loadingPhotos': 'Загрузка фото мест...',
|
||||||
'trip.mobilePlan': 'План',
|
'trip.mobilePlan': 'План',
|
||||||
'trip.mobilePlaces': 'Места',
|
'trip.mobilePlaces': 'Места',
|
||||||
'trip.toast.placeUpdated': 'Место обновлено',
|
'trip.toast.placeUpdated': 'Место обновлено',
|
||||||
@@ -697,9 +781,14 @@ const ru: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Добавить место/активность',
|
'places.addPlace': 'Добавить место/активность',
|
||||||
'places.importGpx': 'Импорт GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||||
'places.gpxError': 'Ошибка импорта GPX',
|
'places.gpxError': 'Ошибка импорта GPX',
|
||||||
|
'places.importGoogleList': 'Список Google',
|
||||||
|
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
|
||||||
|
'places.googleListImported': '{count} мест импортировано из "{list}"',
|
||||||
|
'places.googleListError': 'Не удалось импортировать список Google Maps',
|
||||||
|
'places.viewDetails': 'Подробности',
|
||||||
'places.urlResolved': 'Место импортировано из URL',
|
'places.urlResolved': 'Место импортировано из URL',
|
||||||
'places.assignToDay': 'Добавить в какой день?',
|
'places.assignToDay': 'Добавить в какой день?',
|
||||||
'places.all': 'Все',
|
'places.all': 'Все',
|
||||||
@@ -756,6 +845,7 @@ const ru: Record<string, string> = {
|
|||||||
'inspector.addRes': 'Бронирование',
|
'inspector.addRes': 'Бронирование',
|
||||||
'inspector.editRes': 'Редактировать бронирование',
|
'inspector.editRes': 'Редактировать бронирование',
|
||||||
'inspector.participants': 'Участники',
|
'inspector.participants': 'Участники',
|
||||||
|
'inspector.trackStats': 'Данные маршрута',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Бронирования',
|
'reservations.title': 'Бронирования',
|
||||||
@@ -838,6 +928,7 @@ const ru: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Бюджет',
|
'budget.title': 'Бюджет',
|
||||||
|
'budget.exportCsv': 'Экспорт CSV',
|
||||||
'budget.emptyTitle': 'Бюджет ещё не создан',
|
'budget.emptyTitle': 'Бюджет ещё не создан',
|
||||||
'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки',
|
'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки',
|
||||||
'budget.emptyPlaceholder': 'Введите название категории...',
|
'budget.emptyPlaceholder': 'Введите название категории...',
|
||||||
@@ -852,6 +943,7 @@ const ru: Record<string, string> = {
|
|||||||
'budget.table.perDay': 'В день',
|
'budget.table.perDay': 'В день',
|
||||||
'budget.table.perPersonDay': 'Чел. / день',
|
'budget.table.perPersonDay': 'Чел. / день',
|
||||||
'budget.table.note': 'Заметка',
|
'budget.table.note': 'Заметка',
|
||||||
|
'budget.table.date': 'Дата',
|
||||||
'budget.newEntry': 'Новая запись',
|
'budget.newEntry': 'Новая запись',
|
||||||
'budget.defaultEntry': 'Новая запись',
|
'budget.defaultEntry': 'Новая запись',
|
||||||
'budget.defaultCategory': 'Новая категория',
|
'budget.defaultCategory': 'Новая категория',
|
||||||
@@ -1245,6 +1337,7 @@ const ru: Record<string, string> = {
|
|||||||
'memories.immichUrl': 'URL сервера Immich',
|
'memories.immichUrl': 'URL сервера Immich',
|
||||||
'memories.immichApiKey': 'API-ключ',
|
'memories.immichApiKey': 'API-ключ',
|
||||||
'memories.testConnection': 'Проверить подключение',
|
'memories.testConnection': 'Проверить подключение',
|
||||||
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
'memories.connected': 'Подключено',
|
'memories.connected': 'Подключено',
|
||||||
'memories.disconnected': 'Не подключено',
|
'memories.disconnected': 'Не подключено',
|
||||||
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
||||||
@@ -1254,6 +1347,12 @@ const ru: Record<string, string> = {
|
|||||||
'memories.newest': 'Сначала новые',
|
'memories.newest': 'Сначала новые',
|
||||||
'memories.allLocations': 'Все места',
|
'memories.allLocations': 'Все места',
|
||||||
'memories.addPhotos': 'Добавить фото',
|
'memories.addPhotos': 'Добавить фото',
|
||||||
|
'memories.linkAlbum': 'Привязать альбом',
|
||||||
|
'memories.selectAlbum': 'Выбрать альбом Immich',
|
||||||
|
'memories.noAlbums': 'Альбомы не найдены',
|
||||||
|
'memories.syncAlbum': 'Синхронизировать',
|
||||||
|
'memories.unlinkAlbum': 'Отвязать',
|
||||||
|
'memories.photos': 'фото',
|
||||||
'memories.selectPhotos': 'Выбрать фото из Immich',
|
'memories.selectPhotos': 'Выбрать фото из Immich',
|
||||||
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
||||||
'memories.selected': 'выбрано',
|
'memories.selected': 'выбрано',
|
||||||
@@ -1285,6 +1384,7 @@ const ru: Record<string, string> = {
|
|||||||
'collab.chat.today': 'Сегодня',
|
'collab.chat.today': 'Сегодня',
|
||||||
'collab.chat.yesterday': 'Вчера',
|
'collab.chat.yesterday': 'Вчера',
|
||||||
'collab.chat.deletedMessage': 'удалил(а) сообщение',
|
'collab.chat.deletedMessage': 'удалил(а) сообщение',
|
||||||
|
'collab.chat.reply': 'Ответить',
|
||||||
'collab.chat.loadMore': 'Загрузить старые сообщения',
|
'collab.chat.loadMore': 'Загрузить старые сообщения',
|
||||||
'collab.chat.justNow': 'только что',
|
'collab.chat.justNow': 'только что',
|
||||||
'collab.chat.minutesAgo': '{n} мин. назад',
|
'collab.chat.minutesAgo': '{n} мин. назад',
|
||||||
@@ -1335,6 +1435,55 @@ const ru: Record<string, string> = {
|
|||||||
'collab.polls.options': 'Варианты',
|
'collab.polls.options': 'Варианты',
|
||||||
'collab.polls.delete': 'Удалить',
|
'collab.polls.delete': 'Удалить',
|
||||||
'collab.polls.closedSection': 'Закрытые',
|
'collab.polls.closedSection': 'Закрытые',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Разрешения',
|
||||||
|
'perm.title': 'Настройки разрешений',
|
||||||
|
'perm.subtitle': 'Управляйте тем, кто может выполнять действия в приложении',
|
||||||
|
'perm.saved': 'Настройки разрешений сохранены',
|
||||||
|
'perm.resetDefaults': 'Сбросить по умолчанию',
|
||||||
|
'perm.customized': 'изменено',
|
||||||
|
'perm.level.admin': 'Только администратор',
|
||||||
|
'perm.level.tripOwner': 'Владелец поездки',
|
||||||
|
'perm.level.tripMember': 'Участники поездки',
|
||||||
|
'perm.level.everybody': 'Все',
|
||||||
|
'perm.cat.trip': 'Управление поездками',
|
||||||
|
'perm.cat.members': 'Управление участниками',
|
||||||
|
'perm.cat.files': 'Файлы',
|
||||||
|
'perm.cat.content': 'Контент и расписание',
|
||||||
|
'perm.cat.extras': 'Бюджет, сборы и совместная работа',
|
||||||
|
'perm.action.trip_create': 'Создавать поездки',
|
||||||
|
'perm.action.trip_edit': 'Редактировать детали поездки',
|
||||||
|
'perm.action.trip_delete': 'Удалять поездки',
|
||||||
|
'perm.action.trip_archive': 'Архивировать / разархивировать поездки',
|
||||||
|
'perm.action.trip_cover_upload': 'Загружать обложку',
|
||||||
|
'perm.action.member_manage': 'Добавлять / удалять участников',
|
||||||
|
'perm.action.file_upload': 'Загружать файлы',
|
||||||
|
'perm.action.file_edit': 'Редактировать метаданные файлов',
|
||||||
|
'perm.action.file_delete': 'Удалять файлы',
|
||||||
|
'perm.action.place_edit': 'Добавлять / редактировать / удалять места',
|
||||||
|
'perm.action.day_edit': 'Редактировать дни, заметки и назначения',
|
||||||
|
'perm.action.reservation_edit': 'Управлять бронированиями',
|
||||||
|
'perm.action.budget_edit': 'Управлять бюджетом',
|
||||||
|
'perm.action.packing_edit': 'Управлять списками вещей',
|
||||||
|
'perm.action.collab_edit': 'Совместная работа (заметки, опросы, чат)',
|
||||||
|
'perm.action.share_manage': 'Управлять ссылками для обмена',
|
||||||
|
'perm.actionHint.trip_create': 'Кто может создавать новые поездки',
|
||||||
|
'perm.actionHint.trip_edit': 'Кто может менять название, даты, описание и валюту поездки',
|
||||||
|
'perm.actionHint.trip_delete': 'Кто может безвозвратно удалить поездку',
|
||||||
|
'perm.actionHint.trip_archive': 'Кто может архивировать или разархивировать поездку',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Кто может загружать или менять обложку',
|
||||||
|
'perm.actionHint.member_manage': 'Кто может приглашать или удалять участников поездки',
|
||||||
|
'perm.actionHint.file_upload': 'Кто может загружать файлы в поездку',
|
||||||
|
'perm.actionHint.file_edit': 'Кто может редактировать описания и ссылки файлов',
|
||||||
|
'perm.actionHint.file_delete': 'Кто может перемещать файлы в корзину или безвозвратно удалять',
|
||||||
|
'perm.actionHint.place_edit': 'Кто может добавлять, редактировать или удалять места',
|
||||||
|
'perm.actionHint.day_edit': 'Кто может редактировать дни, заметки к дням и назначения мест',
|
||||||
|
'perm.actionHint.reservation_edit': 'Кто может создавать, редактировать или удалять бронирования',
|
||||||
|
'perm.actionHint.budget_edit': 'Кто может создавать, редактировать или удалять статьи бюджета',
|
||||||
|
'perm.actionHint.packing_edit': 'Кто может управлять вещами для сборов и сумками',
|
||||||
|
'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения',
|
||||||
|
'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ru
|
export default ru
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const zh: Record<string, string> = {
|
|||||||
'common.edit': '编辑',
|
'common.edit': '编辑',
|
||||||
'common.add': '添加',
|
'common.add': '添加',
|
||||||
'common.loading': '加载中...',
|
'common.loading': '加载中...',
|
||||||
|
'common.import': '导入',
|
||||||
'common.error': '错误',
|
'common.error': '错误',
|
||||||
'common.back': '返回',
|
'common.back': '返回',
|
||||||
'common.all': '全部',
|
'common.all': '全部',
|
||||||
@@ -25,6 +26,14 @@ const zh: Record<string, string> = {
|
|||||||
'common.email': '邮箱',
|
'common.email': '邮箱',
|
||||||
'common.password': '密码',
|
'common.password': '密码',
|
||||||
'common.saving': '保存中...',
|
'common.saving': '保存中...',
|
||||||
|
'common.saved': '已保存',
|
||||||
|
'trips.reminder': '提醒',
|
||||||
|
'trips.reminderNone': '无',
|
||||||
|
'trips.reminderDay': '天',
|
||||||
|
'trips.reminderDays': '天',
|
||||||
|
'trips.reminderCustom': '自定义',
|
||||||
|
'trips.reminderDaysBefore': '天前提醒',
|
||||||
|
'trips.reminderDisabledHint': '旅行提醒已禁用。请在管理 > 设置 > 通知中启用。',
|
||||||
'common.update': '更新',
|
'common.update': '更新',
|
||||||
'common.change': '修改',
|
'common.change': '修改',
|
||||||
'common.uploading': '上传中…',
|
'common.uploading': '上传中…',
|
||||||
@@ -149,9 +158,26 @@ const zh: Record<string, string> = {
|
|||||||
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
||||||
'settings.notifyPackingTagged': '行李清单:分配',
|
'settings.notifyPackingTagged': '行李清单:分配',
|
||||||
'settings.notifyWebhook': 'Webhook 通知',
|
'settings.notifyWebhook': 'Webhook 通知',
|
||||||
|
'settings.notificationsDisabled': '通知尚未配置。请联系管理员启用电子邮件或 Webhook 通知。',
|
||||||
|
'settings.notificationsActive': '活跃频道',
|
||||||
|
'settings.notificationsManagedByAdmin': '通知事件由管理员配置。',
|
||||||
|
'admin.notifications.title': '通知',
|
||||||
|
'admin.notifications.hint': '选择一个通知渠道。一次只能激活一个。',
|
||||||
|
'admin.notifications.none': '已禁用',
|
||||||
|
'admin.notifications.email': '电子邮件 (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': '通知事件',
|
||||||
|
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
|
||||||
|
'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook,然后启用事件。',
|
||||||
|
'admin.notifications.save': '保存通知设置',
|
||||||
|
'admin.notifications.saved': '通知设置已保存',
|
||||||
|
'admin.notifications.testWebhook': '发送测试 Webhook',
|
||||||
|
'admin.notifications.testWebhookSuccess': '测试 Webhook 发送成功',
|
||||||
|
'admin.notifications.testWebhookFailed': '测试 Webhook 发送失败',
|
||||||
'admin.smtp.title': '邮件与通知',
|
'admin.smtp.title': '邮件与通知',
|
||||||
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。',
|
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
|
||||||
'admin.smtp.testButton': '发送测试邮件',
|
'admin.smtp.testButton': '发送测试邮件',
|
||||||
|
'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。',
|
||||||
'admin.smtp.testSuccess': '测试邮件发送成功',
|
'admin.smtp.testSuccess': '测试邮件发送成功',
|
||||||
'admin.smtp.testFailed': '测试邮件发送失败',
|
'admin.smtp.testFailed': '测试邮件发送失败',
|
||||||
'dayplan.icsTooltip': '导出日历 (ICS)',
|
'dayplan.icsTooltip': '导出日历 (ICS)',
|
||||||
@@ -185,6 +211,31 @@ const zh: Record<string, string> = {
|
|||||||
'share.permCollab': '聊天',
|
'share.permCollab': '聊天',
|
||||||
'settings.on': '开',
|
'settings.on': '开',
|
||||||
'settings.off': '关',
|
'settings.off': '关',
|
||||||
|
'settings.mcp.title': 'MCP 配置',
|
||||||
|
'settings.mcp.endpoint': 'MCP 端点',
|
||||||
|
'settings.mcp.clientConfig': '客户端配置',
|
||||||
|
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
|
||||||
|
'settings.mcp.copy': '复制',
|
||||||
|
'settings.mcp.copied': '已复制!',
|
||||||
|
'settings.mcp.apiTokens': 'API 令牌',
|
||||||
|
'settings.mcp.createToken': '创建新令牌',
|
||||||
|
'settings.mcp.noTokens': '暂无令牌,请创建一个以连接 MCP 客户端。',
|
||||||
|
'settings.mcp.tokenCreatedAt': '创建于',
|
||||||
|
'settings.mcp.tokenUsedAt': '使用于',
|
||||||
|
'settings.mcp.deleteTokenTitle': '删除令牌',
|
||||||
|
'settings.mcp.deleteTokenMessage': '此令牌将立即失效,使用它的所有 MCP 客户端将失去访问权限。',
|
||||||
|
'settings.mcp.modal.createTitle': '创建 API 令牌',
|
||||||
|
'settings.mcp.modal.tokenName': '令牌名称',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': '例如:Claude Desktop、工作电脑',
|
||||||
|
'settings.mcp.modal.creating': '创建中…',
|
||||||
|
'settings.mcp.modal.create': '创建令牌',
|
||||||
|
'settings.mcp.modal.createdTitle': '令牌已创建',
|
||||||
|
'settings.mcp.modal.createdWarning': '此令牌只会显示一次,请立即复制并妥善保存——无法找回。',
|
||||||
|
'settings.mcp.modal.done': '完成',
|
||||||
|
'settings.mcp.toast.created': '令牌已创建',
|
||||||
|
'settings.mcp.toast.createError': '创建令牌失败',
|
||||||
|
'settings.mcp.toast.deleted': '令牌已删除',
|
||||||
|
'settings.mcp.toast.deleteError': '删除令牌失败',
|
||||||
'settings.account': '账户',
|
'settings.account': '账户',
|
||||||
'settings.username': '用户名',
|
'settings.username': '用户名',
|
||||||
'settings.email': '邮箱',
|
'settings.email': '邮箱',
|
||||||
@@ -192,6 +243,7 @@ const zh: Record<string, string> = {
|
|||||||
'settings.roleAdmin': '管理员',
|
'settings.roleAdmin': '管理员',
|
||||||
'settings.oidcLinked': '已关联',
|
'settings.oidcLinked': '已关联',
|
||||||
'settings.changePassword': '修改密码',
|
'settings.changePassword': '修改密码',
|
||||||
|
'settings.mustChangePassword': '您必须更改密码才能继续。请在下方设置新密码。',
|
||||||
'settings.currentPassword': '当前密码',
|
'settings.currentPassword': '当前密码',
|
||||||
'settings.currentPasswordRequired': '请输入当前密码',
|
'settings.currentPasswordRequired': '请输入当前密码',
|
||||||
'settings.newPassword': '新密码',
|
'settings.newPassword': '新密码',
|
||||||
@@ -200,7 +252,7 @@ const zh: Record<string, string> = {
|
|||||||
'settings.passwordRequired': '请输入当前密码和新密码',
|
'settings.passwordRequired': '请输入当前密码和新密码',
|
||||||
'settings.passwordTooShort': '密码至少需要 8 个字符',
|
'settings.passwordTooShort': '密码至少需要 8 个字符',
|
||||||
'settings.passwordMismatch': '两次输入的密码不一致',
|
'settings.passwordMismatch': '两次输入的密码不一致',
|
||||||
'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字',
|
'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符',
|
||||||
'settings.passwordChanged': '密码修改成功',
|
'settings.passwordChanged': '密码修改成功',
|
||||||
'settings.deleteAccount': '删除账户',
|
'settings.deleteAccount': '删除账户',
|
||||||
'settings.deleteAccountTitle': '确定删除账户?',
|
'settings.deleteAccountTitle': '确定删除账户?',
|
||||||
@@ -212,6 +264,14 @@ const zh: Record<string, string> = {
|
|||||||
'settings.saveProfile': '保存资料',
|
'settings.saveProfile': '保存资料',
|
||||||
'settings.mfa.title': '双因素认证 (2FA)',
|
'settings.mfa.title': '双因素认证 (2FA)',
|
||||||
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
|
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
|
||||||
|
'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。',
|
||||||
|
'settings.mfa.backupTitle': '备用代码',
|
||||||
|
'settings.mfa.backupDescription': '如果你无法使用身份验证器应用,可使用这些一次性备用代码登录。',
|
||||||
|
'settings.mfa.backupWarning': '请立即保存这些代码。每个代码只能使用一次。',
|
||||||
|
'settings.mfa.backupCopy': '复制代码',
|
||||||
|
'settings.mfa.backupDownload': '下载 TXT',
|
||||||
|
'settings.mfa.backupPrint': '打印 / PDF',
|
||||||
|
'settings.mfa.backupCopied': '备用代码已复制',
|
||||||
'settings.mfa.enabled': '您的账户已启用 2FA。',
|
'settings.mfa.enabled': '您的账户已启用 2FA。',
|
||||||
'settings.mfa.disabled': '2FA 未启用。',
|
'settings.mfa.disabled': '2FA 未启用。',
|
||||||
'settings.mfa.setup': '设置身份验证器',
|
'settings.mfa.setup': '设置身份验证器',
|
||||||
@@ -263,6 +323,8 @@ const zh: Record<string, string> = {
|
|||||||
'login.signIn': '登录',
|
'login.signIn': '登录',
|
||||||
'login.createAdmin': '创建管理员账户',
|
'login.createAdmin': '创建管理员账户',
|
||||||
'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
|
'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
|
||||||
|
'login.setNewPassword': '设置新密码',
|
||||||
|
'login.setNewPasswordHint': '您必须更改密码才能继续。',
|
||||||
'login.createAccount': '创建账户',
|
'login.createAccount': '创建账户',
|
||||||
'login.createAccountHint': '注册新账户。',
|
'login.createAccountHint': '注册新账户。',
|
||||||
'login.creating': '创建中…',
|
'login.creating': '创建中…',
|
||||||
@@ -289,7 +351,7 @@ const zh: Record<string, string> = {
|
|||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': '两次输入的密码不一致',
|
'register.passwordMismatch': '两次输入的密码不一致',
|
||||||
'register.passwordTooShort': '密码至少需要 6 个字符',
|
'register.passwordTooShort': '密码至少需要 8 个字符',
|
||||||
'register.failed': '注册失败',
|
'register.failed': '注册失败',
|
||||||
'register.getStarted': '开始使用',
|
'register.getStarted': '开始使用',
|
||||||
'register.subtitle': '创建账户,开始规划你的梦想旅行。',
|
'register.subtitle': '创建账户,开始规划你的梦想旅行。',
|
||||||
@@ -365,6 +427,8 @@ const zh: Record<string, string> = {
|
|||||||
'admin.tabs.settings': '设置',
|
'admin.tabs.settings': '设置',
|
||||||
'admin.allowRegistration': '允许注册',
|
'admin.allowRegistration': '允许注册',
|
||||||
'admin.allowRegistrationHint': '新用户可以自行注册',
|
'admin.allowRegistrationHint': '新用户可以自行注册',
|
||||||
|
'admin.requireMfa': '要求双因素身份验证(2FA)',
|
||||||
|
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
|
||||||
'admin.apiKeys': 'API 密钥',
|
'admin.apiKeys': 'API 密钥',
|
||||||
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
||||||
'admin.mapsKey': 'Google Maps API 密钥',
|
'admin.mapsKey': 'Google Maps API 密钥',
|
||||||
@@ -418,8 +482,10 @@ const zh: Record<string, string> = {
|
|||||||
'admin.tabs.addons': '扩展',
|
'admin.tabs.addons': '扩展',
|
||||||
'admin.addons.title': '扩展',
|
'admin.addons.title': '扩展',
|
||||||
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
|
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
|
||||||
'admin.addons.catalog.memories.name': '回忆',
|
'admin.addons.catalog.memories.name': '照片 (Immich)',
|
||||||
'admin.addons.catalog.memories.description': '每次旅行的共享相册',
|
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
|
||||||
'admin.addons.catalog.packing.name': '行李',
|
'admin.addons.catalog.packing.name': '行李',
|
||||||
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
|
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
|
||||||
'admin.addons.catalog.budget.name': '预算',
|
'admin.addons.catalog.budget.name': '预算',
|
||||||
@@ -438,8 +504,10 @@ const zh: Record<string, string> = {
|
|||||||
'admin.addons.disabled': '已禁用',
|
'admin.addons.disabled': '已禁用',
|
||||||
'admin.addons.type.trip': '旅行',
|
'admin.addons.type.trip': '旅行',
|
||||||
'admin.addons.type.global': '全局',
|
'admin.addons.type.global': '全局',
|
||||||
|
'admin.addons.type.integration': '集成',
|
||||||
'admin.addons.tripHint': '在每次旅行中作为标签页显示',
|
'admin.addons.tripHint': '在每次旅行中作为标签页显示',
|
||||||
'admin.addons.globalHint': '在主导航中作为独立板块显示',
|
'admin.addons.globalHint': '在主导航中作为独立板块显示',
|
||||||
|
'admin.addons.integrationHint': '后端服务和 API 集成,无专属页面',
|
||||||
'admin.addons.toast.updated': '扩展已更新',
|
'admin.addons.toast.updated': '扩展已更新',
|
||||||
'admin.addons.toast.error': '更新扩展失败',
|
'admin.addons.toast.error': '更新扩展失败',
|
||||||
'admin.addons.noAddons': '暂无可用扩展',
|
'admin.addons.noAddons': '暂无可用扩展',
|
||||||
@@ -455,6 +523,22 @@ const zh: Record<string, string> = {
|
|||||||
'admin.weather.requestsDesc': '免费,无需 API 密钥',
|
'admin.weather.requestsDesc': '免费,无需 API 密钥',
|
||||||
'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
|
'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
|
||||||
|
|
||||||
|
// MCP Tokens
|
||||||
|
'admin.tabs.mcpTokens': 'MCP 令牌',
|
||||||
|
'admin.mcpTokens.title': 'MCP 令牌',
|
||||||
|
'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌',
|
||||||
|
'admin.mcpTokens.owner': '所有者',
|
||||||
|
'admin.mcpTokens.tokenName': '令牌名称',
|
||||||
|
'admin.mcpTokens.created': '创建时间',
|
||||||
|
'admin.mcpTokens.lastUsed': '最后使用',
|
||||||
|
'admin.mcpTokens.never': '从未',
|
||||||
|
'admin.mcpTokens.empty': '尚未创建任何 MCP 令牌',
|
||||||
|
'admin.mcpTokens.deleteTitle': '删除令牌',
|
||||||
|
'admin.mcpTokens.deleteMessage': '此令牌将立即被撤销。用户将失去通过此令牌的 MCP 访问权限。',
|
||||||
|
'admin.mcpTokens.deleteSuccess': '令牌已删除',
|
||||||
|
'admin.mcpTokens.deleteError': '删除令牌失败',
|
||||||
|
'admin.mcpTokens.loadError': '加载令牌失败',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
@@ -636,9 +720,8 @@ const zh: Record<string, string> = {
|
|||||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||||
'atlas.addToBucket': '添加到心愿单',
|
'atlas.addToBucket': '添加到心愿单',
|
||||||
'atlas.addPoi': '添加地点',
|
'atlas.addPoi': '添加地点',
|
||||||
'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)',
|
'atlas.searchCountry': '搜索国家...',
|
||||||
'atlas.month': '月份',
|
'atlas.month': '月份',
|
||||||
'atlas.year': '年份',
|
|
||||||
'atlas.addToBucketHint': '保存为想去的地方',
|
'atlas.addToBucketHint': '保存为想去的地方',
|
||||||
'atlas.bucketWhen': '你计划什么时候去?',
|
'atlas.bucketWhen': '你计划什么时候去?',
|
||||||
|
|
||||||
@@ -651,6 +734,7 @@ const zh: Record<string, string> = {
|
|||||||
'trip.tabs.budget': '预算',
|
'trip.tabs.budget': '预算',
|
||||||
'trip.tabs.files': '文件',
|
'trip.tabs.files': '文件',
|
||||||
'trip.loading': '加载旅行中...',
|
'trip.loading': '加载旅行中...',
|
||||||
|
'trip.loadingPhotos': '正在加载地点照片...',
|
||||||
'trip.mobilePlan': '计划',
|
'trip.mobilePlan': '计划',
|
||||||
'trip.mobilePlaces': '地点',
|
'trip.mobilePlaces': '地点',
|
||||||
'trip.toast.placeUpdated': '地点已更新',
|
'trip.toast.placeUpdated': '地点已更新',
|
||||||
@@ -697,9 +781,14 @@ const zh: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': '添加地点/活动',
|
'places.addPlace': '添加地点/活动',
|
||||||
'places.importGpx': '导入 GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||||
'places.gpxError': 'GPX 导入失败',
|
'places.gpxError': 'GPX 导入失败',
|
||||||
|
'places.importGoogleList': 'Google 列表',
|
||||||
|
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
|
||||||
|
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
|
||||||
|
'places.googleListError': 'Google Maps 列表导入失败',
|
||||||
|
'places.viewDetails': '查看详情',
|
||||||
'places.urlResolved': '已从 URL 导入地点',
|
'places.urlResolved': '已从 URL 导入地点',
|
||||||
'places.assignToDay': '添加到哪一天?',
|
'places.assignToDay': '添加到哪一天?',
|
||||||
'places.all': '全部',
|
'places.all': '全部',
|
||||||
@@ -756,6 +845,7 @@ const zh: Record<string, string> = {
|
|||||||
'inspector.addRes': '预订',
|
'inspector.addRes': '预订',
|
||||||
'inspector.editRes': '编辑预订',
|
'inspector.editRes': '编辑预订',
|
||||||
'inspector.participants': '参与者',
|
'inspector.participants': '参与者',
|
||||||
|
'inspector.trackStats': '轨迹数据',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': '预订',
|
'reservations.title': '预订',
|
||||||
@@ -838,6 +928,7 @@ const zh: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': '预算',
|
'budget.title': '预算',
|
||||||
|
'budget.exportCsv': '导出 CSV',
|
||||||
'budget.emptyTitle': '尚未创建预算',
|
'budget.emptyTitle': '尚未创建预算',
|
||||||
'budget.emptyText': '创建分类和条目来规划旅行预算',
|
'budget.emptyText': '创建分类和条目来规划旅行预算',
|
||||||
'budget.emptyPlaceholder': '输入分类名称...',
|
'budget.emptyPlaceholder': '输入分类名称...',
|
||||||
@@ -852,6 +943,7 @@ const zh: Record<string, string> = {
|
|||||||
'budget.table.perDay': '日均',
|
'budget.table.perDay': '日均',
|
||||||
'budget.table.perPersonDay': '人日均',
|
'budget.table.perPersonDay': '人日均',
|
||||||
'budget.table.note': '备注',
|
'budget.table.note': '备注',
|
||||||
|
'budget.table.date': '日期',
|
||||||
'budget.newEntry': '新建条目',
|
'budget.newEntry': '新建条目',
|
||||||
'budget.defaultEntry': '新建条目',
|
'budget.defaultEntry': '新建条目',
|
||||||
'budget.defaultCategory': '新分类',
|
'budget.defaultCategory': '新分类',
|
||||||
@@ -1245,6 +1337,7 @@ const zh: Record<string, string> = {
|
|||||||
'memories.immichUrl': 'Immich 服务器地址',
|
'memories.immichUrl': 'Immich 服务器地址',
|
||||||
'memories.immichApiKey': 'API 密钥',
|
'memories.immichApiKey': 'API 密钥',
|
||||||
'memories.testConnection': '测试连接',
|
'memories.testConnection': '测试连接',
|
||||||
|
'memories.testFirst': '请先测试连接',
|
||||||
'memories.connected': '已连接',
|
'memories.connected': '已连接',
|
||||||
'memories.disconnected': '未连接',
|
'memories.disconnected': '未连接',
|
||||||
'memories.connectionSuccess': '已连接到 Immich',
|
'memories.connectionSuccess': '已连接到 Immich',
|
||||||
@@ -1254,6 +1347,12 @@ const zh: Record<string, string> = {
|
|||||||
'memories.newest': '最新优先',
|
'memories.newest': '最新优先',
|
||||||
'memories.allLocations': '所有地点',
|
'memories.allLocations': '所有地点',
|
||||||
'memories.addPhotos': '添加照片',
|
'memories.addPhotos': '添加照片',
|
||||||
|
'memories.linkAlbum': '关联相册',
|
||||||
|
'memories.selectAlbum': '选择 Immich 相册',
|
||||||
|
'memories.noAlbums': '未找到相册',
|
||||||
|
'memories.syncAlbum': '同步相册',
|
||||||
|
'memories.unlinkAlbum': '取消关联',
|
||||||
|
'memories.photos': '张照片',
|
||||||
'memories.selectPhotos': '从 Immich 选择照片',
|
'memories.selectPhotos': '从 Immich 选择照片',
|
||||||
'memories.selectHint': '点击照片以选择。',
|
'memories.selectHint': '点击照片以选择。',
|
||||||
'memories.selected': '已选择',
|
'memories.selected': '已选择',
|
||||||
@@ -1285,6 +1384,7 @@ const zh: Record<string, string> = {
|
|||||||
'collab.chat.today': '今天',
|
'collab.chat.today': '今天',
|
||||||
'collab.chat.yesterday': '昨天',
|
'collab.chat.yesterday': '昨天',
|
||||||
'collab.chat.deletedMessage': '删除了一条消息',
|
'collab.chat.deletedMessage': '删除了一条消息',
|
||||||
|
'collab.chat.reply': '回复',
|
||||||
'collab.chat.loadMore': '加载更早的消息',
|
'collab.chat.loadMore': '加载更早的消息',
|
||||||
'collab.chat.justNow': '刚刚',
|
'collab.chat.justNow': '刚刚',
|
||||||
'collab.chat.minutesAgo': '{n} 分钟前',
|
'collab.chat.minutesAgo': '{n} 分钟前',
|
||||||
@@ -1335,6 +1435,55 @@ const zh: Record<string, string> = {
|
|||||||
'collab.polls.options': '选项',
|
'collab.polls.options': '选项',
|
||||||
'collab.polls.delete': '删除',
|
'collab.polls.delete': '删除',
|
||||||
'collab.polls.closedSection': '已关闭',
|
'collab.polls.closedSection': '已关闭',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': '权限',
|
||||||
|
'perm.title': '权限设置',
|
||||||
|
'perm.subtitle': '控制谁可以在应用中执行操作',
|
||||||
|
'perm.saved': '权限设置已保存',
|
||||||
|
'perm.resetDefaults': '恢复默认',
|
||||||
|
'perm.customized': '已自定义',
|
||||||
|
'perm.level.admin': '仅管理员',
|
||||||
|
'perm.level.tripOwner': '旅行所有者',
|
||||||
|
'perm.level.tripMember': '旅行成员',
|
||||||
|
'perm.level.everybody': '所有人',
|
||||||
|
'perm.cat.trip': '旅行管理',
|
||||||
|
'perm.cat.members': '成员管理',
|
||||||
|
'perm.cat.files': '文件',
|
||||||
|
'perm.cat.content': '内容与日程',
|
||||||
|
'perm.cat.extras': '预算、行李与协作',
|
||||||
|
'perm.action.trip_create': '创建旅行',
|
||||||
|
'perm.action.trip_edit': '编辑旅行详情',
|
||||||
|
'perm.action.trip_delete': '删除旅行',
|
||||||
|
'perm.action.trip_archive': '归档 / 取消归档旅行',
|
||||||
|
'perm.action.trip_cover_upload': '上传封面图片',
|
||||||
|
'perm.action.member_manage': '添加 / 移除成员',
|
||||||
|
'perm.action.file_upload': '上传文件',
|
||||||
|
'perm.action.file_edit': '编辑文件元数据',
|
||||||
|
'perm.action.file_delete': '删除文件',
|
||||||
|
'perm.action.place_edit': '添加 / 编辑 / 删除地点',
|
||||||
|
'perm.action.day_edit': '编辑日程、备注与分配',
|
||||||
|
'perm.action.reservation_edit': '管理预订',
|
||||||
|
'perm.action.budget_edit': '管理预算',
|
||||||
|
'perm.action.packing_edit': '管理行李清单',
|
||||||
|
'perm.action.collab_edit': '协作(笔记、投票、聊天)',
|
||||||
|
'perm.action.share_manage': '管理分享链接',
|
||||||
|
'perm.actionHint.trip_create': '谁可以创建新旅行',
|
||||||
|
'perm.actionHint.trip_edit': '谁可以更改旅行名称、日期、描述和货币',
|
||||||
|
'perm.actionHint.trip_delete': '谁可以永久删除旅行',
|
||||||
|
'perm.actionHint.trip_archive': '谁可以归档或取消归档旅行',
|
||||||
|
'perm.actionHint.trip_cover_upload': '谁可以上传或更改封面图片',
|
||||||
|
'perm.actionHint.member_manage': '谁可以邀请或移除旅行成员',
|
||||||
|
'perm.actionHint.file_upload': '谁可以向旅行上传文件',
|
||||||
|
'perm.actionHint.file_edit': '谁可以编辑文件描述和链接',
|
||||||
|
'perm.actionHint.file_delete': '谁可以将文件移至回收站或永久删除',
|
||||||
|
'perm.actionHint.place_edit': '谁可以添加、编辑或删除地点',
|
||||||
|
'perm.actionHint.day_edit': '谁可以编辑日程、日程备注和地点分配',
|
||||||
|
'perm.actionHint.reservation_edit': '谁可以创建、编辑或删除预订',
|
||||||
|
'perm.actionHint.budget_edit': '谁可以创建、编辑或删除预算项目',
|
||||||
|
'perm.actionHint.packing_edit': '谁可以管理行李物品和包袋',
|
||||||
|
'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息',
|
||||||
|
'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default zh
|
export default zh
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../store/addonStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { getApiErrorMessage } from '../types'
|
import { getApiErrorMessage } from '../types'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
@@ -14,7 +15,9 @@ import GitHubPanel from '../components/Admin/GitHubPanel'
|
|||||||
import AddonManager from '../components/Admin/AddonManager'
|
import AddonManager from '../components/Admin/AddonManager'
|
||||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
|
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||||
|
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||||
|
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
@@ -42,6 +45,7 @@ interface OidcConfig {
|
|||||||
client_secret_set: boolean
|
client_secret_set: boolean
|
||||||
display_name: string
|
display_name: string
|
||||||
oidc_only: boolean
|
oidc_only: boolean
|
||||||
|
discovery_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
@@ -56,6 +60,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const { demoMode, serverTimezone } = useAuthStore()
|
const { demoMode, serverTimezone } = useAuthStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'users', label: t('admin.tabs.users') },
|
{ id: 'users', label: t('admin.tabs.users') },
|
||||||
{ id: 'config', label: t('admin.tabs.config') },
|
{ id: 'config', label: t('admin.tabs.config') },
|
||||||
@@ -63,6 +68,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||||
|
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||||
{ id: 'github', label: t('admin.tabs.github') },
|
{ id: 'github', label: t('admin.tabs.github') },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -80,11 +86,12 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||||
|
|
||||||
// OIDC config
|
// OIDC config
|
||||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
|
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
|
||||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||||
|
|
||||||
// Registration toggle
|
// Registration toggle
|
||||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||||
|
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||||
|
|
||||||
// Invite links
|
// Invite links
|
||||||
const [invites, setInvites] = useState<any[]>([])
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
@@ -116,13 +123,14 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
// Version check & update
|
// Version check & update
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||||
const [updating, setUpdating] = useState<boolean>(false)
|
|
||||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
|
||||||
|
|
||||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
|
||||||
|
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
loadAppConfig()
|
loadAppConfig()
|
||||||
@@ -155,6 +163,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
const config = await authApi.getAppConfig()
|
const config = await authApi.getAppConfig()
|
||||||
setAllowRegistration(config.allow_registration)
|
setAllowRegistration(config.allow_registration)
|
||||||
|
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -171,26 +180,6 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInstallUpdate = async () => {
|
|
||||||
setUpdating(true)
|
|
||||||
setUpdateResult(null)
|
|
||||||
try {
|
|
||||||
await adminApi.installUpdate()
|
|
||||||
setUpdateResult('success')
|
|
||||||
// Server is restarting — poll until it comes back, then reload
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await authApi.getAppConfig()
|
|
||||||
clearInterval(poll)
|
|
||||||
window.location.reload()
|
|
||||||
} catch { /* still restarting */ }
|
|
||||||
}, 2000)
|
|
||||||
} catch {
|
|
||||||
setUpdateResult('error')
|
|
||||||
setUpdating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleRegistration = async (value) => {
|
const handleToggleRegistration = async (value) => {
|
||||||
setAllowRegistration(value)
|
setAllowRegistration(value)
|
||||||
try {
|
try {
|
||||||
@@ -201,6 +190,18 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleRequireMfa = async (value: boolean) => {
|
||||||
|
setRequireMfa(value)
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({ require_mfa: value })
|
||||||
|
setAppRequireMfa(value)
|
||||||
|
toast.success(t('common.saved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setRequireMfa(!value)
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleKey = (key) => {
|
const toggleKey = (key) => {
|
||||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -253,6 +254,10 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
toast.error(t('admin.toast.fieldsRequired'))
|
toast.error(t('admin.toast.fieldsRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (createForm.password.trim().length < 8) {
|
||||||
|
toast.error(t('settings.passwordTooShort'))
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.createUser(createForm)
|
const data = await adminApi.createUser(createForm)
|
||||||
setUsers(prev => [data.user, ...prev])
|
setUsers(prev => [data.user, ...prev])
|
||||||
@@ -308,7 +313,13 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
email: editForm.email.trim() || undefined,
|
email: editForm.email.trim() || undefined,
|
||||||
role: editForm.role,
|
role: editForm.role,
|
||||||
}
|
}
|
||||||
if (editForm.password.trim()) payload.password = editForm.password.trim()
|
if (editForm.password.trim()) {
|
||||||
|
if (editForm.password.trim().length < 8) {
|
||||||
|
toast.error(t('settings.passwordTooShort'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.password = editForm.password.trim()
|
||||||
|
}
|
||||||
const data = await adminApi.updateUser(editingUser.id, payload)
|
const data = await adminApi.updateUser(editingUser.id, payload)
|
||||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
||||||
setEditingUser(null)
|
setEditingUser(null)
|
||||||
@@ -376,23 +387,13 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
{t('admin.update.button')}
|
{t('admin.update.button')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{updateInfo.is_docker ? (
|
<button
|
||||||
<button
|
onClick={() => setShowUpdateModal(true)}
|
||||||
onClick={() => setShowUpdateModal(true)}
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
>
|
||||||
>
|
<Download className="w-4 h-4" />
|
||||||
<Download className="w-4 h-4" />
|
{t('admin.update.howTo')}
|
||||||
{t('admin.update.howTo')}
|
</button>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUpdateModal(true)}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
{t('admin.update.install')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -618,6 +619,8 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && <div className="mt-6"><PermissionsPanel /></div>}
|
||||||
|
|
||||||
{/* Create Invite Modal */}
|
{/* Create Invite Modal */}
|
||||||
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -692,14 +695,38 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
|
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
allowRegistration ? 'translate-x-6' : 'translate-x-1'
|
style={{ transform: allowRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||||
}`}
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Require 2FA for all users */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: requireMfa ? 'translateX(20px)' : 'translateX(0)' }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -869,6 +896,17 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={oidcConfig.discovery_url}
|
||||||
|
onChange={e => setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))}
|
||||||
|
placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration'
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
||||||
<input
|
<input
|
||||||
@@ -896,14 +934,12 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
|
||||||
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
|
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
|
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -912,7 +948,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setSavingOidc(true)
|
setSavingOidc(true)
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
|
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url }
|
||||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||||
await adminApi.updateOidc(payload)
|
await adminApi.updateOidc(payload)
|
||||||
toast.success(t('admin.oidcSaved'))
|
toast.success(t('admin.oidcSaved'))
|
||||||
@@ -930,49 +966,211 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* SMTP / Notifications */}
|
{/* Notifications — exclusive channel selector */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-3">
|
<div className="p-6 space-y-4">
|
||||||
{smtpLoaded && [
|
{/* Channel selector */}
|
||||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
<div className="flex gap-2">
|
||||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
{(['none', 'email', 'webhook'] as const).map(ch => {
|
||||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
const active = (smtpValues.notification_channel || 'none') === ch
|
||||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
|
||||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
return (
|
||||||
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
|
<button
|
||||||
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
|
key={ch}
|
||||||
].map(field => (
|
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
|
||||||
<div key={field.key}>
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
>
|
||||||
<input
|
{labels[ch]}
|
||||||
type={field.type || 'text'}
|
</button>
|
||||||
value={smtpValues[field.key] || ''}
|
)
|
||||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
})}
|
||||||
placeholder={field.placeholder}
|
</div>
|
||||||
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
{/* Notification event toggles — shown when any channel is active */}
|
||||||
/>
|
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
|
||||||
|
const ch = smtpValues.notification_channel || 'none'
|
||||||
|
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
||||||
|
{!configValid && (
|
||||||
|
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
||||||
|
{[
|
||||||
|
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||||
|
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
||||||
|
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
|
||||||
|
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
|
||||||
|
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
|
||||||
|
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
|
||||||
|
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
|
||||||
|
].map(opt => {
|
||||||
|
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
|
||||||
|
return (
|
||||||
|
<div key={opt.key} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-sm text-slate-700">{opt.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newVal = isOn ? 'false' : 'true'
|
||||||
|
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
|
||||||
|
}}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
<button
|
})()}
|
||||||
onClick={async () => {
|
|
||||||
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
{/* Email (SMTP) settings — shown when email channel is active */}
|
||||||
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||||
}
|
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||||
try {
|
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
|
||||||
const result = await notificationsApi.testSmtp()
|
{smtpLoaded && [
|
||||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||||
}}
|
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||||
>
|
].map(field => (
|
||||||
{t('admin.smtp.testButton')}
|
<div key={field.key}>
|
||||||
</button>
|
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
value={smtpValues[field.key] || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => {
|
||||||
|
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||||
|
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||||
|
}}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Webhook settings — shown when webhook channel is active */}
|
||||||
|
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||||
|
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={smtpValues.notification_webhook_url || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save + Test buttons */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings(payload)
|
||||||
|
toast.success(t('admin.notifications.saved'))
|
||||||
|
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||||
|
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||||
|
}).catch(() => {})
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||||
|
await authApi.updateAppSettings(payload).catch(() => {})
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testSmtp()
|
||||||
|
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||||
|
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.smtp.testButton')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (smtpValues.notification_webhook_url) {
|
||||||
|
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testWebhook()
|
||||||
|
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
|
||||||
|
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.notifications.testWebhook')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||||
|
<h2 className="font-semibold text-red-700 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Danger Zone
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRotateJwtModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Rotate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -980,7 +1178,9 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
|
|
||||||
{activeTab === 'backup' && <BackupPanel />}
|
{activeTab === 'backup' && <BackupPanel />}
|
||||||
|
|
||||||
{activeTab === 'audit' && <AuditLogPanel />}
|
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||||
|
|
||||||
|
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
|
||||||
|
|
||||||
{activeTab === 'github' && <GitHubPanel />}
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
</div>
|
</div>
|
||||||
@@ -1122,78 +1322,37 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Update confirmation popup — matches backup restore style */}
|
{/* Update instructions popup */}
|
||||||
{showUpdateModal && (
|
{showUpdateModal && (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
onClick={() => { if (!updating) setShowUpdateModal(false) }}
|
onClick={() => setShowUpdateModal(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
{updateResult === 'success' ? (
|
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<>
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<ArrowUpCircle size={20} style={{ color: 'white' }} />
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
</div>
|
||||||
<CheckCircle size={20} style={{ color: 'white' }} />
|
<div>
|
||||||
</div>
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.howTo')}</h3>
|
||||||
<div>
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
|
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
|
</div>
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : updateResult === 'error' ? (
|
|
||||||
<>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
<XCircle size={20} style={{ color: 'white' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
|
|
||||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
|
||||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Red header */}
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
|
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
|
||||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
<div style={{ padding: '20px 24px' }}>
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
{updateInfo?.is_docker ? (
|
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||||
<>
|
</p>
|
||||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
|
||||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||||
>
|
>
|
||||||
{`docker pull mauriceboe/nomad:latest
|
{`docker pull mauriceboe/nomad:latest
|
||||||
docker stop nomad && docker rm nomad
|
docker stop nomad && docker rm nomad
|
||||||
docker run -d --name nomad \\
|
docker run -d --name nomad \\
|
||||||
@@ -1202,90 +1361,93 @@ docker run -d --name nomad \\
|
|||||||
-v /opt/nomad/uploads:/app/uploads \\
|
-v /opt/nomad/uploads:/app/uploads \\
|
||||||
--restart unless-stopped \\
|
--restart unless-stopped \\
|
||||||
mauriceboe/nomad:latest`}
|
mauriceboe/nomad:latest`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{t('admin.update.dataInfo')}</span>
|
<span>{t('admin.update.dataInfo')}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
|
||||||
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
|
||||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{t('admin.update.dataInfo')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
|
||||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>
|
|
||||||
{t('admin.update.backupHint')}{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
|
|
||||||
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
|
|
||||||
>{t('admin.update.backupLink')}</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
|
||||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{t('admin.update.warning')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{updateInfo?.release_url && (
|
||||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
<button
|
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||||
onClick={() => setShowUpdateModal(false)}
|
>
|
||||||
disabled={updating}
|
<div className="flex items-start gap-2">
|
||||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
|
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
<span>
|
||||||
>
|
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
|
||||||
{t('common.cancel')}
|
{t('admin.update.button')}
|
||||||
</button>
|
</a>
|
||||||
{!updateInfo?.is_docker && (
|
</span>
|
||||||
<button
|
</div>
|
||||||
onClick={handleInstallUpdate}
|
|
||||||
disabled={updating}
|
|
||||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
|
|
||||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{updating ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download size={14} />
|
|
||||||
)}
|
|
||||||
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateModal(false)}
|
||||||
|
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rotate JWT Secret confirmation modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showRotateJwtModal}
|
||||||
|
onClose={() => setShowRotateJwtModal(false)}
|
||||||
|
title="Rotate JWT Secret"
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRotateJwtModal(false)}
|
||||||
|
disabled={rotatingJwt}
|
||||||
|
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRotatingJwt(true)
|
||||||
|
try {
|
||||||
|
await adminApi.rotateJwtSecret()
|
||||||
|
setShowRotateJwtModal(false)
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
setRotatingJwt(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={rotatingJwt}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
|
||||||
|
>
|
||||||
|
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
|
Rotate & Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
|
||||||
|
<p className="text-xs text-slate-500">A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react'
|
import React, { useEffect, useMemo, useState, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
@@ -127,6 +127,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const glareRef = useRef<HTMLDivElement>(null)
|
const glareRef = useRef<HTMLDivElement>(null)
|
||||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
|
||||||
|
|
||||||
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||||
@@ -139,7 +140,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
// Border glow that follows cursor
|
// Border glow that follows cursor
|
||||||
borderGlareRef.current.style.opacity = '1'
|
borderGlareRef.current.style.opacity = '1'
|
||||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
}
|
}
|
||||||
const handlePanelMouseLeave = () => {
|
const handlePanelMouseLeave = () => {
|
||||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||||
@@ -170,6 +171,26 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
||||||
const bucketMarkersRef = useRef<any>(null)
|
const bucketMarkersRef = useRef<any>(null)
|
||||||
|
|
||||||
|
const [atlas_country_search, set_atlas_country_search] = useState('')
|
||||||
|
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
|
||||||
|
const [atlas_country_open, set_atlas_country_open] = useState(false)
|
||||||
|
|
||||||
|
const atlas_country_options = useMemo(() => {
|
||||||
|
if (!geoData) return []
|
||||||
|
const opts: { code: string; label: string }[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const f of (geoData as any).features || []) {
|
||||||
|
const a2 = f?.properties?.ISO_A2
|
||||||
|
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
|
||||||
|
if (seen.has(a2)) continue
|
||||||
|
seen.add(a2)
|
||||||
|
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
|
||||||
|
opts.push({ code: a2, label })
|
||||||
|
}
|
||||||
|
opts.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
return opts
|
||||||
|
}, [geoData, resolveName])
|
||||||
|
|
||||||
// Load atlas data + bucket list
|
// Load atlas data + bucket list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -231,8 +252,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
updateWhenIdle: false,
|
updateWhenIdle: false,
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
zoomOffset: 0,
|
zoomOffset: 0,
|
||||||
crossOrigin: true,
|
crossOrigin: true
|
||||||
loading: true,
|
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
// Preload adjacent zoom level tiles
|
// Preload adjacent zoom level tiles
|
||||||
@@ -292,6 +312,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||||
const c = countryMap[a3]
|
const c = countryMap[a3]
|
||||||
if (c) {
|
if (c) {
|
||||||
|
country_layer_by_a2_ref.current[c.code] = layer
|
||||||
const name = resolveName(c.code)
|
const name = resolveName(c.code)
|
||||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
||||||
const tooltipHtml = `
|
const tooltipHtml = `
|
||||||
@@ -337,6 +358,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const isoA2 = feature.properties?.ISO_A2
|
const isoA2 = feature.properties?.ISO_A2
|
||||||
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
|
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
|
||||||
if (countryCode && countryCode !== '-99') {
|
if (countryCode && countryCode !== '-99') {
|
||||||
|
country_layer_by_a2_ref.current[countryCode] = layer
|
||||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
@@ -366,6 +388,23 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const select_country_from_search = (country_code: string): void => {
|
||||||
|
const country_label = resolveName(country_code)
|
||||||
|
set_atlas_country_search(country_label)
|
||||||
|
set_atlas_country_open(false)
|
||||||
|
set_atlas_country_results([])
|
||||||
|
|
||||||
|
const layer = country_layer_by_a2_ref.current[country_code]
|
||||||
|
try {
|
||||||
|
if (layer?.getBounds && mapInstance.current) {
|
||||||
|
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
|
||||||
|
}
|
||||||
|
} catch (e ) {
|
||||||
|
console.error('Error fitting bounds', e)
|
||||||
|
}
|
||||||
|
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||||
|
}
|
||||||
|
|
||||||
const executeConfirmAction = async (): Promise<void> => {
|
const executeConfirmAction = async (): Promise<void> => {
|
||||||
if (!confirmAction) return
|
if (!confirmAction) return
|
||||||
const { type, code } = confirmAction
|
const { type, code } = confirmAction
|
||||||
@@ -494,6 +533,129 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||||
|
<div
|
||||||
|
className="absolute z-20 flex justify-center"
|
||||||
|
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||||
|
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
|
||||||
|
backdropFilter: 'blur(18px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||||
|
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
|
||||||
|
}}>
|
||||||
|
<Search size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
value={atlas_country_search}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
set_atlas_country_search(raw)
|
||||||
|
const q = raw.trim().toLowerCase()
|
||||||
|
if (!q) {
|
||||||
|
set_atlas_country_results([])
|
||||||
|
set_atlas_country_open(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const results = atlas_country_options
|
||||||
|
.filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q)
|
||||||
|
.slice(0, 8)
|
||||||
|
set_atlas_country_results(results)
|
||||||
|
set_atlas_country_open(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (atlas_country_results.length > 0) set_atlas_country_open(true)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
set_atlas_country_open(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const first = atlas_country_results[0]
|
||||||
|
if (first) select_country_from_search(first.code)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('atlas.searchCountry')}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{atlas_country_search.trim() && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
set_atlas_country_search('')
|
||||||
|
set_atlas_country_results([])
|
||||||
|
set_atlas_country_open(false)
|
||||||
|
}}
|
||||||
|
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{atlas_country_open && atlas_country_results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||||
|
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
|
||||||
|
backdropFilter: 'blur(18px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||||
|
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => set_atlas_country_open(false)}
|
||||||
|
>
|
||||||
|
{atlas_country_results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.code}
|
||||||
|
onClick={() => select_country_from_search(r.code)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '10px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textAlign: 'left',
|
||||||
|
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||||
|
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{r.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile: Bottom bar */}
|
{/* Mobile: Bottom bar */}
|
||||||
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
||||||
@@ -551,7 +713,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||||
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||||
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||||
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||||
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||||
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
||||||
t={t} dark={dark}
|
t={t} dark={dark}
|
||||||
@@ -629,24 +791,24 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={bucketMonth}
|
value={String(bucketMonth)}
|
||||||
onChange={v => setBucketMonth(Number(v))}
|
onChange={v => setBucketMonth(Number(v))}
|
||||||
placeholder={t('atlas.month')}
|
placeholder={t('atlas.month')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: '—' },
|
{ value: '0', label: '—' },
|
||||||
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||||
]}
|
]}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={bucketYear}
|
value={String(bucketYear)}
|
||||||
onChange={v => setBucketYear(Number(v))}
|
onChange={v => setBucketYear(Number(v))}
|
||||||
placeholder={t('atlas.year')}
|
placeholder={t('atlas.year')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: '—' },
|
{ value: '0', label: '—' },
|
||||||
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
|
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
|
||||||
]}
|
]}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -717,6 +879,7 @@ interface SidebarContentProps {
|
|||||||
onSearchBucket: () => Promise<void>
|
onSearchBucket: () => Promise<void>
|
||||||
onSelectBucketPoi: (result: any) => void
|
onSelectBucketPoi: (result: any) => void
|
||||||
bucketSearchResults: any[]
|
bucketSearchResults: any[]
|
||||||
|
setBucketSearchResults: (v: string[]) => void
|
||||||
bucketPoiMonth: number
|
bucketPoiMonth: number
|
||||||
setBucketPoiMonth: (v: number) => void
|
setBucketPoiMonth: (v: number) => void
|
||||||
bucketPoiYear: number
|
bucketPoiYear: number
|
||||||
@@ -728,7 +891,7 @@ interface SidebarContentProps {
|
|||||||
dark: boolean
|
dark: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
@@ -854,12 +1017,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{/* Month / Year with CustomSelect */}
|
{/* Month / Year with CustomSelect */}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
<CustomSelect value={String(bucketPoiMonth)} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
<CustomSelect value={String(bucketPoiYear)} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
|
options={[{ value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import DemoBanner from '../components/Layout/DemoBanner'
|
|||||||
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
||||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||||
LayoutGrid, List,
|
LayoutGrid, List,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useCanDo } from '../store/permissionsStore'
|
||||||
|
|
||||||
interface DashboardTrip {
|
interface DashboardTrip {
|
||||||
id: number
|
id: number
|
||||||
@@ -138,9 +140,9 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
|
|||||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||||
interface TripCardProps {
|
interface TripCardProps {
|
||||||
trip: DashboardTrip
|
trip: DashboardTrip
|
||||||
onEdit: (trip: DashboardTrip) => void
|
onEdit?: (trip: DashboardTrip) => void
|
||||||
onDelete: (trip: DashboardTrip) => void
|
onDelete?: (trip: DashboardTrip) => void
|
||||||
onArchive: (id: number) => void
|
onArchive?: (id: number) => void
|
||||||
onClick: (trip: DashboardTrip) => void
|
onClick: (trip: DashboardTrip) => void
|
||||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||||
locale: string
|
locale: string
|
||||||
@@ -186,12 +188,14 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right actions */}
|
{/* Top-right actions */}
|
||||||
|
{(onEdit || onArchive || onDelete) && (
|
||||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
|
||||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
|
||||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom content */}
|
{/* Bottom content */}
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||||
@@ -305,12 +309,14 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
|||||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(onEdit || onArchive || onDelete) && (
|
||||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
|
||||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
|
||||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -403,11 +409,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{(onEdit || onArchive || onDelete) && (
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
|
||||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
|
||||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -415,9 +423,9 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
|||||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||||
interface ArchivedRowProps {
|
interface ArchivedRowProps {
|
||||||
trip: DashboardTrip
|
trip: DashboardTrip
|
||||||
onEdit: (trip: DashboardTrip) => void
|
onEdit?: (trip: DashboardTrip) => void
|
||||||
onUnarchive: (id: number) => void
|
onUnarchive?: (id: number) => void
|
||||||
onDelete: (trip: DashboardTrip) => void
|
onDelete?: (trip: DashboardTrip) => void
|
||||||
onClick: (trip: DashboardTrip) => void
|
onClick: (trip: DashboardTrip) => void
|
||||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||||
locale: string
|
locale: string
|
||||||
@@ -427,11 +435,11 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
|||||||
return (
|
return (
|
||||||
<div onClick={() => onClick(trip)} style={{
|
<div onClick={() => onClick(trip)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||||
borderRadius: 12, border: '1px solid #f3f4f6', background: 'white', cursor: 'pointer',
|
borderRadius: 12, border: '1px solid var(--border-faint)', background: 'var(--bg-card)', cursor: 'pointer',
|
||||||
transition: 'border-color 0.12s',
|
transition: 'border-color 0.12s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-faint)'}>
|
||||||
{/* Mini cover */}
|
{/* Mini cover */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||||
@@ -440,8 +448,8 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
|||||||
}} />
|
}} />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
||||||
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
{!trip.is_owner && <span style={{ fontSize: 10, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
||||||
</div>
|
</div>
|
||||||
{trip.start_date && (
|
{trip.start_date && (
|
||||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
||||||
@@ -449,18 +457,20 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(onEdit || onUnarchive || onDelete) && (
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#6b7280' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||||
<ArchiveRestore size={12} /> {t('dashboard.restore')}
|
<ArchiveRestore size={12} /> {t('dashboard.restore')}
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#9ca3af' }}
|
{onDelete && <button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -527,6 +537,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -541,6 +552,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
|
const can = useCanDo()
|
||||||
const dm = settings.dark_mode
|
const dm = settings.dark_mode
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const showCurrency = settings.dashboard_currency !== 'off'
|
const showCurrency = settings.dashboard_currency !== 'off'
|
||||||
@@ -595,16 +607,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (trip) => {
|
const handleDelete = (trip) => setDeleteTrip(trip)
|
||||||
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTrip) return
|
||||||
try {
|
try {
|
||||||
await tripsApi.delete(trip.id)
|
await tripsApi.delete(deleteTrip.id)
|
||||||
setTrips(prev => prev.filter(t => t.id !== trip.id))
|
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
||||||
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
|
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
||||||
toast.success(t('dashboard.toast.deleted'))
|
toast.success(t('dashboard.toast.deleted'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.deleteError'))
|
toast.error(t('dashboard.toast.deleteError'))
|
||||||
}
|
}
|
||||||
|
setDeleteTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleArchive = async (id) => {
|
const handleArchive = async (id) => {
|
||||||
@@ -666,7 +680,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: '0 14px',
|
padding: '0 14px', height: 37,
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
transition: 'background 0.15s, border-color 0.15s',
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
@@ -681,7 +695,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: '0 14px',
|
padding: '0 14px', height: 37,
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
transition: 'background 0.15s, border-color 0.15s',
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
@@ -691,7 +705,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
<Settings size={15} />
|
<Settings size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{can('trip_create') && <button
|
||||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||||
@@ -703,7 +717,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -768,12 +782,12 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
|
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
|
||||||
{t('dashboard.emptyText')}
|
{t('dashboard.emptyText')}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{can('trip_create') && <button
|
||||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
>
|
>
|
||||||
<Plus size={16} /> {t('dashboard.emptyButton')}
|
<Plus size={16} /> {t('dashboard.emptyButton')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -782,9 +796,9 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale} dark={dark}
|
t={t} locale={locale} dark={dark}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||||
onDelete={handleDelete}
|
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
|
||||||
onArchive={handleArchive}
|
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -798,9 +812,9 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||||
onDelete={handleDelete}
|
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||||
onArchive={handleArchive}
|
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -812,9 +826,9 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||||
onDelete={handleDelete}
|
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||||
onArchive={handleArchive}
|
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -842,9 +856,9 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||||
onUnarchive={handleUnarchive}
|
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
|
||||||
onDelete={handleDelete}
|
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -893,6 +907,14 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
onCoverUpdate={handleCoverUpdate}
|
onCoverUpdate={handleCoverUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteTrip}
|
||||||
|
onClose={() => setDeleteTrip(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title={t('common.delete')}
|
||||||
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe,
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
has_users: boolean
|
has_users: boolean
|
||||||
allow_registration: boolean
|
allow_registration: boolean
|
||||||
|
setup_complete: boolean
|
||||||
demo_mode: boolean
|
demo_mode: boolean
|
||||||
oidc_configured: boolean
|
oidc_configured: boolean
|
||||||
oidc_display_name?: string
|
oidc_display_name?: string
|
||||||
@@ -28,7 +29,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const [inviteToken, setInviteToken] = useState<string>('')
|
const [inviteToken, setInviteToken] = useState<string>('')
|
||||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||||
|
|
||||||
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
|
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
||||||
const { setLanguageLocal } = useSettingsStore()
|
const { setLanguageLocal } = useSettingsStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -54,11 +55,11 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
if (oidcCode) {
|
if (oidcCode) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(async data => {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
localStorage.setItem('auth_token', data.token)
|
await loadUser()
|
||||||
navigate('/dashboard', { replace: true })
|
navigate('/dashboard', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'OIDC login failed')
|
setError(data.error || 'OIDC login failed')
|
||||||
@@ -110,26 +111,46 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const [mfaStep, setMfaStep] = useState(false)
|
const [mfaStep, setMfaStep] = useState(false)
|
||||||
const [mfaToken, setMfaToken] = useState('')
|
const [mfaToken, setMfaToken] = useState('')
|
||||||
const [mfaCode, setMfaCode] = useState('')
|
const [mfaCode, setMfaCode] = useState('')
|
||||||
|
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
|
||||||
|
const [savedLoginPassword, setSavedLoginPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
|
if (passwordChangeStep) {
|
||||||
|
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
|
||||||
|
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
|
||||||
|
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
|
||||||
|
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
||||||
|
await loadUser({ silent: true })
|
||||||
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (mode === 'login' && mfaStep) {
|
if (mode === 'login' && mfaStep) {
|
||||||
if (!mfaCode.trim()) {
|
if (!mfaCode.trim()) {
|
||||||
setError(t('login.mfaCodeRequired'))
|
setError(t('login.mfaCodeRequired'))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await completeMfaLogin(mfaToken, mfaCode)
|
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||||
|
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||||
|
setSavedLoginPassword(password)
|
||||||
|
setPasswordChangeStep(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return }
|
||||||
await register(username, email, password, inviteToken || undefined)
|
await register(username, email, password, inviteToken || undefined)
|
||||||
} else {
|
} else {
|
||||||
const result = await login(email, password)
|
const result = await login(email, password)
|
||||||
@@ -140,6 +161,12 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ('user' in result && result.user?.must_change_password) {
|
||||||
|
setSavedLoginPassword(password)
|
||||||
|
setPasswordChangeStep(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
@@ -149,7 +176,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
|
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
|
||||||
|
|
||||||
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
||||||
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
||||||
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||||
{mode === 'login' && mfaStep
|
{passwordChangeStep
|
||||||
? t('login.mfaTitle')
|
? t('login.setNewPassword')
|
||||||
: mode === 'register'
|
: mode === 'login' && mfaStep
|
||||||
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
? t('login.mfaTitle')
|
||||||
: t('login.title')}
|
: mode === 'register'
|
||||||
|
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
||||||
|
: t('login.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||||
{mode === 'login' && mfaStep
|
{passwordChangeStep
|
||||||
? t('login.mfaSubtitle')
|
? t('login.setNewPasswordHint')
|
||||||
: mode === 'register'
|
: mode === 'login' && mfaStep
|
||||||
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
? t('login.mfaSubtitle')
|
||||||
: t('login.subtitle')}
|
: mode === 'register'
|
||||||
|
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
||||||
|
: t('login.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
@@ -537,18 +568,50 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'login' && mfaStep && (
|
{passwordChangeStep && (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 13, color: '#92400e' }}>
|
||||||
|
{t('settings.mustChangePassword')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.newPassword')}</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||||
|
<input
|
||||||
|
type="password" value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)} required
|
||||||
|
placeholder={t('settings.newPassword')} style={inputBase}
|
||||||
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.confirmPassword')}</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||||
|
<input
|
||||||
|
type="password" value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)} required
|
||||||
|
placeholder={t('settings.confirmPassword')} style={inputBase}
|
||||||
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'login' && mfaStep && !passwordChangeStep && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="text"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
value={mfaCode}
|
value={mfaCode}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||||
placeholder="000000"
|
placeholder="000000 or XXXX-XXXX"
|
||||||
required
|
required
|
||||||
style={inputBase}
|
style={inputBase}
|
||||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Username (register only) */}
|
{/* Username (register only) */}
|
||||||
{mode === 'register' && (
|
{mode === 'register' && !passwordChangeStep && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
{!(mode === 'login' && mfaStep) && (
|
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
{!(mode === 'login' && mfaStep) && (
|
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
||||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
: <><Plane size={16} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Toggle login/register */}
|
{/* Toggle login/register */}
|
||||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (
|
||||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
setError(t('register.passwordTooShort'))
|
setError(t('register.passwordTooShort'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react'
|
||||||
import { authApi, adminApi, notificationsApi } from '../api/client'
|
import { authApi, adminApi } from '../api/client'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
import { useAddonStore } from '../store/addonStore'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { UserWithOidc } from '../types'
|
import type { UserWithOidc } from '../types'
|
||||||
import { getApiErrorMessage } from '../types'
|
import { getApiErrorMessage } from '../types'
|
||||||
@@ -18,6 +19,15 @@ interface MapPreset {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||||
|
interface McpToken {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
created_at: string
|
||||||
|
last_used_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const MAP_PRESETS: MapPreset[] = [
|
const MAP_PRESETS: MapPreset[] = [
|
||||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||||
@@ -46,99 +56,103 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
|
function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
|
return (
|
||||||
const [addons, setAddons] = useState<Record<string, boolean>>({})
|
<button onClick={onToggle}
|
||||||
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
|
style={{
|
||||||
|
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||||
|
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||||
|
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
||||||
|
const [notifChannel, setNotifChannel] = useState<string>('none')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons').then(r => {
|
authApi.getAppConfig?.().then((cfg: any) => {
|
||||||
const map: Record<string, boolean> = {}
|
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
|
||||||
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
|
|
||||||
setAddons(map)
|
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggle = async (key: string) => {
|
if (notifChannel === 'none') {
|
||||||
if (!prefs) return
|
return (
|
||||||
const newVal = prefs[key] ? 0 : 1
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||||
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
|
{t('settings.notificationsDisabled')}
|
||||||
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
|
</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
|
const channelLabel = notifChannel === 'email'
|
||||||
|
? (t('admin.notifications.email') || 'Email (SMTP)')
|
||||||
const options = [
|
: (t('admin.notifications.webhook') || 'Webhook')
|
||||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
|
||||||
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
|
||||||
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
|
|
||||||
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
|
|
||||||
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
|
|
||||||
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
|
|
||||||
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{options.map(opt => (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
|
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
<button onClick={() => toggle(opt.key)}
|
{t('settings.notificationsActive')}: {channelLabel}
|
||||||
style={{
|
</span>
|
||||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
</div>
|
||||||
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
||||||
transition: 'background 0.2s',
|
{t('settings.notificationsManagedByAdmin')}
|
||||||
}}>
|
</p>
|
||||||
<span style={{
|
|
||||||
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
|
|
||||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
|
||||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
||||||
}} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export default function SettingsPage(): React.ReactElement {
|
||||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||||
|
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// Immich
|
// Addon gating (derived from store)
|
||||||
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
|
const memoriesEnabled = addonEnabled('memories')
|
||||||
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
const [immichUrl, setImmichUrl] = useState('')
|
const [immichUrl, setImmichUrl] = useState('')
|
||||||
const [immichApiKey, setImmichApiKey] = useState('')
|
const [immichApiKey, setImmichApiKey] = useState('')
|
||||||
const [immichConnected, setImmichConnected] = useState(false)
|
const [immichConnected, setImmichConnected] = useState(false)
|
||||||
const [immichTesting, setImmichTesting] = useState(false)
|
const [immichTesting, setImmichTesting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons').then(r => {
|
loadAddons()
|
||||||
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
|
|
||||||
setMemoriesEnabled(!!mem)
|
|
||||||
if (mem) {
|
|
||||||
apiClient.get('/integrations/immich/settings').then(r2 => {
|
|
||||||
setImmichUrl(r2.data.immich_url || '')
|
|
||||||
setImmichConnected(r2.data.connected)
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (memoriesEnabled) {
|
||||||
|
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||||
|
setImmichUrl(r2.data.immich_url || '')
|
||||||
|
setImmichConnected(r2.data.connected)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [memoriesEnabled])
|
||||||
|
|
||||||
|
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||||
|
|
||||||
const handleSaveImmich = async () => {
|
const handleSaveImmich = async () => {
|
||||||
setSaving(s => ({ ...s, immich: true }))
|
setSaving(s => ({ ...s, immich: true }))
|
||||||
try {
|
try {
|
||||||
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||||
|
if (saveRes.data.warning) toast.warn(saveRes.data.warning)
|
||||||
toast.success(t('memories.saved'))
|
toast.success(t('memories.saved'))
|
||||||
// Test connection
|
|
||||||
const res = await apiClient.get('/integrations/immich/status')
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
setImmichConnected(res.data.connected)
|
setImmichConnected(res.data.connected)
|
||||||
|
setImmichTestPassed(false)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('memories.connectionError'))
|
toast.error(t('memories.connectionError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,13 +163,13 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
const handleTestImmich = async () => {
|
const handleTestImmich = async () => {
|
||||||
setImmichTesting(true)
|
setImmichTesting(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get('/integrations/immich/status')
|
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||||
if (res.data.connected) {
|
if (res.data.connected) {
|
||||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||||
setImmichConnected(true)
|
setImmichTestPassed(true)
|
||||||
} else {
|
} else {
|
||||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||||
setImmichConnected(false)
|
setImmichTestPassed(false)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('memories.connectionError'))
|
toast.error(t('memories.connectionError'))
|
||||||
@@ -164,6 +178,67 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP tokens
|
||||||
|
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||||
|
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||||
|
const [mcpNewName, setMcpNewName] = useState('')
|
||||||
|
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||||
|
const [mcpCreating, setMcpCreating] = useState(false)
|
||||||
|
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreateMcpToken = async () => {
|
||||||
|
if (!mcpNewName.trim()) return
|
||||||
|
setMcpCreating(true)
|
||||||
|
try {
|
||||||
|
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||||
|
setMcpCreatedToken(d.token.raw_token)
|
||||||
|
setMcpNewName('')
|
||||||
|
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.mcp.toast.createError'))
|
||||||
|
} finally {
|
||||||
|
setMcpCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMcpToken = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await authApi.mcpTokens.delete(id)
|
||||||
|
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||||
|
setMcpDeleteId(null)
|
||||||
|
toast.success(t('settings.mcp.toast.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.mcp.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (text: string, key: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopiedKey(key)
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||||
|
const mcpJsonConfig = `{
|
||||||
|
"mcpServers": {
|
||||||
|
"trek": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"${mcpEndpoint}",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer <your_token>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
// Map settings
|
// Map settings
|
||||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||||
@@ -193,6 +268,71 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||||
const [mfaLoading, setMfaLoading] = useState(false)
|
const [mfaLoading, setMfaLoading] = useState(false)
|
||||||
|
const mfaRequiredByPolicy =
|
||||||
|
!demoMode &&
|
||||||
|
!user?.mfa_enabled &&
|
||||||
|
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||||
|
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||||
|
|
||||||
|
const backupCodesText = backupCodes?.join('\n') || ''
|
||||||
|
|
||||||
|
// Restore backup codes panel after refresh (loadUser silent fix + sessionStorage)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.mfa_enabled || backupCodes) return
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) {
|
||||||
|
setBackupCodes(parsed)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||||
|
}
|
||||||
|
}, [user?.mfa_enabled, backupCodes])
|
||||||
|
|
||||||
|
const dismissBackupCodes = (): void => {
|
||||||
|
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||||
|
setBackupCodes(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyBackupCodes = async (): Promise<void> => {
|
||||||
|
if (!backupCodesText) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(backupCodesText)
|
||||||
|
toast.success(t('settings.mfa.backupCopied'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadBackupCodes = (): void => {
|
||||||
|
if (!backupCodesText) return
|
||||||
|
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'trek-mfa-backup-codes.txt'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const printBackupCodes = (): void => {
|
||||||
|
if (!backupCodesText) return
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||||
|
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||||
|
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||||
|
const w = window.open('', '_blank', 'width=900,height=700')
|
||||||
|
if (!w) return
|
||||||
|
w.document.open()
|
||||||
|
w.document.write(html)
|
||||||
|
w.document.close()
|
||||||
|
w.focus()
|
||||||
|
w.print()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
@@ -288,7 +428,7 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value=""
|
value={mapTileUrl}
|
||||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||||
options={MAP_PRESETS.map(p => ({
|
options={MAP_PRESETS.map(p => ({
|
||||||
@@ -539,19 +679,20 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||||
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
<input type="url" value={immichUrl} onChange={e => { setImmichUrl(e.target.value); setImmichTestPassed(false) }}
|
||||||
placeholder="https://immich.example.com"
|
placeholder="https://immich.example.com"
|
||||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||||
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
<input type="password" value={immichApiKey} onChange={e => { setImmichApiKey(e.target.value); setImmichTestPassed(false) }}
|
||||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={handleSaveImmich} disabled={saving.immich}
|
<button onClick={handleSaveImmich} disabled={saving.immich || !immichTestPassed}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||||
|
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||||
<Save className="w-4 h-4" /> {t('common.save')}
|
<Save className="w-4 h-4" /> {t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||||
@@ -572,6 +713,162 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* MCP Configuration — only when MCP addon is enabled */}
|
||||||
|
{mcpEnabled && <Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||||
|
{/* Endpoint URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||||
|
{mcpEndpoint}
|
||||||
|
</code>
|
||||||
|
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||||
|
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||||
|
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON config box */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||||
|
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||||
|
{mcpJsonConfig}
|
||||||
|
</pre>
|
||||||
|
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||||
|
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mcpTokens.length === 0 ? (
|
||||||
|
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
{t('settings.mcp.noTokens')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
{mcpTokens.map((token, i) => (
|
||||||
|
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||||
|
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{token.token_prefix}...
|
||||||
|
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||||
|
{token.last_used_at && (
|
||||||
|
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setMcpDeleteId(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('settings.mcp.deleteTokenTitle')}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>}
|
||||||
|
|
||||||
|
{/* Create MCP Token modal */}
|
||||||
|
{mcpModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}>
|
||||||
|
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
{!mcpCreatedToken ? (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||||
|
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||||
|
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||||
|
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||||
|
autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
|
<button onClick={() => setMcpModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
|
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||||
|
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||||
|
{mcpCreatedToken}
|
||||||
|
</pre>
|
||||||
|
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||||
|
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||||
|
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
|
{t('settings.mcp.modal.done')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete MCP Token confirm */}
|
||||||
|
{mcpDeleteId !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||||
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setMcpDeleteId(null)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||||
|
{t('settings.mcp.deleteTokenTitle')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Section title={t('settings.account')} icon={User}>
|
<Section title={t('settings.account')} icon={User}>
|
||||||
<div>
|
<div>
|
||||||
@@ -629,6 +926,7 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||||
toast.success(t('settings.passwordChanged'))
|
toast.success(t('settings.passwordChanged'))
|
||||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||||
|
await loadUser({ silent: true })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
}
|
}
|
||||||
@@ -652,6 +950,19 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{mfaRequiredByPolicy && (
|
||||||
|
<div
|
||||||
|
className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderColor: 'var(--border-primary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||||
|
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||||
{demoMode ? (
|
{demoMode ? (
|
||||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||||
@@ -709,12 +1020,21 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setMfaLoading(true)
|
setMfaLoading(true)
|
||||||
try {
|
try {
|
||||||
await authApi.mfaEnable({ code: mfaSetupCode })
|
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||||
toast.success(t('settings.mfa.toastEnabled'))
|
toast.success(t('settings.mfa.toastEnabled'))
|
||||||
setMfaQr(null)
|
setMfaQr(null)
|
||||||
setMfaSecret(null)
|
setMfaSecret(null)
|
||||||
setMfaSetupCode('')
|
setMfaSetupCode('')
|
||||||
await loadUser()
|
const codes = resp.backup_codes || null
|
||||||
|
if (codes?.length) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes))
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBackupCodes(codes)
|
||||||
|
await loadUser({ silent: true })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -766,7 +1086,9 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
toast.success(t('settings.mfa.toastDisabled'))
|
toast.success(t('settings.mfa.toastDisabled'))
|
||||||
setMfaDisablePwd('')
|
setMfaDisablePwd('')
|
||||||
setMfaDisableCode('')
|
setMfaDisableCode('')
|
||||||
await loadUser()
|
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||||
|
setBackupCodes(null)
|
||||||
|
await loadUser({ silent: true })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -779,6 +1101,29 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{backupCodes && backupCodes.length > 0 && (
|
||||||
|
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||||
|
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||||
|
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||||
|
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||||
|
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.ok')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import { getLocaleForLanguage } from '../i18n'
|
||||||
import { shareApi } from '../api/client'
|
import { shareApi } from '../api/client'
|
||||||
import { getCategoryIcon } from '../components/shared/categoryIcons'
|
import { getCategoryIcon } from '../components/shared/categoryIcons'
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
@@ -43,7 +44,6 @@ export default function SharedTripPage() {
|
|||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState('plan')
|
const [activeTab, setActiveTab] = useState('plan')
|
||||||
const { updateSetting } = useSettingsStore()
|
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,7 +127,11 @@ export default function SharedTripPage() {
|
|||||||
{showLangPicker && (
|
{showLangPicker && (
|
||||||
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||||
{SUPPORTED_LANGUAGES.map(lang => (
|
{SUPPORTED_LANGUAGES.map(lang => (
|
||||||
<button key={lang.value} onClick={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
|
<button key={lang.value} onClick={() => {
|
||||||
|
// Set language locally without API call (shared page has no auth)
|
||||||
|
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||||
|
setShowLangPicker(false)
|
||||||
|
}}
|
||||||
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
@@ -164,7 +168,7 @@ export default function SharedTripPage() {
|
|||||||
{activeTab === 'plan' && (<>
|
{activeTab === 'plan' && (<>
|
||||||
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
|
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
|
||||||
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
|
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" referrerPolicy="strict-origin-when-cross-origin" />
|
||||||
<FitBoundsToPlaces places={mapPlaces} />
|
<FitBoundsToPlaces places={mapPlaces} />
|
||||||
{mapPlaces.map((p: any) => (
|
{mapPlaces.map((p: any) => (
|
||||||
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
|
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
|
import { useCanDo } from '../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { MapView } from '../components/Map/MapView'
|
import { MapView } from '../components/Map/MapView'
|
||||||
|
import { getCached, fetchPhoto } from '../services/photoService'
|
||||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||||
@@ -22,7 +24,7 @@ import Navbar from '../components/Layout/Navbar'
|
|||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||||
@@ -36,8 +38,21 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
const tripStore = useTripStore()
|
const trip = useTripStore(s => s.trip)
|
||||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
const days = useTripStore(s => s.days)
|
||||||
|
const places = useTripStore(s => s.places)
|
||||||
|
const assignments = useTripStore(s => s.assignments)
|
||||||
|
const packingItems = useTripStore(s => s.packingItems)
|
||||||
|
const categories = useTripStore(s => s.categories)
|
||||||
|
const reservations = useTripStore(s => s.reservations)
|
||||||
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const files = useTripStore(s => s.files)
|
||||||
|
const selectedDayId = useTripStore(s => s.selectedDayId)
|
||||||
|
const isLoading = useTripStore(s => s.isLoading)
|
||||||
|
// Actions — stable references, don't cause re-renders
|
||||||
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
|
const can = useCanDo()
|
||||||
|
const canUploadFiles = can('file_upload', trip)
|
||||||
|
|
||||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
||||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||||
@@ -47,7 +62,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const loadAccommodations = useCallback(() => {
|
const loadAccommodations = useCallback(() => {
|
||||||
if (tripId) {
|
if (tripId) {
|
||||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
tripStore.loadReservations(tripId)
|
tripActions.loadReservations(tripId)
|
||||||
}
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
@@ -80,8 +95,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const handleTabChange = (tabId: string): void => {
|
const handleTabChange = (tabId: string): void => {
|
||||||
setActiveTab(tabId)
|
setActiveTab(tabId)
|
||||||
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
||||||
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
|
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
||||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
|
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
||||||
}
|
}
|
||||||
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
||||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||||
@@ -98,11 +113,33 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Start photo fetches during splash screen so images are ready when map mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !places || places.length === 0) return
|
||||||
|
for (const p of places) {
|
||||||
|
if (p.image_url) continue
|
||||||
|
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
||||||
|
if (!cacheKey || getCached(cacheKey)) continue
|
||||||
|
const photoId = p.google_place_id || p.osm_id
|
||||||
|
if (photoId || (p.lat && p.lng)) {
|
||||||
|
fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoading, places])
|
||||||
|
|
||||||
// Load trip + files (needed for place inspector file section)
|
// Load trip + files (needed for place inspector file section)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) {
|
if (tripId) {
|
||||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||||
tripStore.loadFiles(tripId)
|
tripActions.loadFiles(tripId)
|
||||||
loadAccommodations()
|
loadAccommodations()
|
||||||
tripsApi.getMembers(tripId).then(d => {
|
tripsApi.getMembers(tripId).then(d => {
|
||||||
// Combine owner + members into one list
|
// Combine owner + members into one list
|
||||||
@@ -113,30 +150,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) tripStore.loadReservations(tripId)
|
if (tripId) tripActions.loadReservations(tripId)
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
|
|
||||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
||||||
|
|
||||||
|
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
||||||
|
|
||||||
const mapPlaces = useMemo(() => {
|
const mapPlaces = useMemo(() => {
|
||||||
|
// Build set of place IDs assigned to collapsed days
|
||||||
|
const hiddenPlaceIds = new Set<number>()
|
||||||
|
if (expandedDayIds) {
|
||||||
|
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||||
|
if (!expandedDayIds.has(Number(dayId))) {
|
||||||
|
for (const a of dayAssignments) {
|
||||||
|
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't hide places that are also assigned to an expanded day
|
||||||
|
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||||
|
if (expandedDayIds.has(Number(dayId))) {
|
||||||
|
for (const a of dayAssignments) {
|
||||||
|
hiddenPlaceIds.delete(a.place?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return places.filter(p => {
|
return places.filter(p => {
|
||||||
if (!p.lat || !p.lng) return false
|
if (!p.lat || !p.lng) return false
|
||||||
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
||||||
|
if (hiddenPlaceIds.has(p.id)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter])
|
}, [places, mapCategoryFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
tripStore.setSelectedDay(dayId)
|
tripActions.setSelectedDay(dayId)
|
||||||
if (changed && !skipFit) setFitKey(k => k + 1)
|
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||||
setMobileSidebarOpen(null)
|
setMobileSidebarOpen(null)
|
||||||
updateRouteForDay(dayId)
|
updateRouteForDay(dayId)
|
||||||
}, [tripStore, updateRouteForDay, selectedDayId])
|
}, [updateRouteForDay, selectedDayId])
|
||||||
|
|
||||||
const handlePlaceClick = useCallback((placeId, assignmentId) => {
|
const handlePlaceClick = useCallback((placeId, assignmentId) => {
|
||||||
if (assignmentId) {
|
if (assignmentId) {
|
||||||
@@ -158,6 +218,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleMapContextMenu = useCallback(async (e) => {
|
const handleMapContextMenu = useCallback(async (e) => {
|
||||||
|
if (!can('place_edit', trip)) return
|
||||||
e.originalEvent?.preventDefault()
|
e.originalEvent?.preventDefault()
|
||||||
const { lat, lng } = e.latlng
|
const { lat, lng } = e.latlng
|
||||||
setPrefillCoords({ lat, lng })
|
setPrefillCoords({ lat, lng })
|
||||||
@@ -179,11 +240,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
if (editingPlace) {
|
if (editingPlace) {
|
||||||
// Always strip time fields from place update — time is per-assignment only
|
// Always strip time fields from place update — time is per-assignment only
|
||||||
const { place_time, end_time, ...placeData } = data
|
const { place_time, end_time, ...placeData } = data
|
||||||
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
|
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
|
||||||
// If editing from assignment context, save time per-assignment
|
// If editing from assignment context, save time per-assignment
|
||||||
if (editingAssignmentId) {
|
if (editingAssignmentId) {
|
||||||
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
||||||
await tripStore.refreshDays(tripId)
|
await tripActions.refreshDays(tripId)
|
||||||
}
|
}
|
||||||
// Upload pending files with place_id
|
// Upload pending files with place_id
|
||||||
if (pendingFiles?.length > 0) {
|
if (pendingFiles?.length > 0) {
|
||||||
@@ -191,23 +252,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
fd.append('place_id', editingPlace.id)
|
fd.append('place_id', editingPlace.id)
|
||||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
try { await tripActions.addFile(tripId, fd) } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success(t('trip.toast.placeUpdated'))
|
toast.success(t('trip.toast.placeUpdated'))
|
||||||
} else {
|
} else {
|
||||||
const place = await tripStore.addPlace(tripId, data)
|
const place = await tripActions.addPlace(tripId, data)
|
||||||
if (pendingFiles?.length > 0 && place?.id) {
|
if (pendingFiles?.length > 0 && place?.id) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
fd.append('place_id', place.id)
|
fd.append('place_id', place.id)
|
||||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
try { await tripActions.addFile(tripId, fd) } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success(t('trip.toast.placeAdded'))
|
toast.success(t('trip.toast.placeAdded'))
|
||||||
}
|
}
|
||||||
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
|
}, [editingPlace, editingAssignmentId, tripId, toast])
|
||||||
|
|
||||||
const handleDeletePlace = useCallback((placeId) => {
|
const handleDeletePlace = useCallback((placeId) => {
|
||||||
setDeletePlaceId(placeId)
|
setDeletePlaceId(placeId)
|
||||||
@@ -216,34 +277,34 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const confirmDeletePlace = useCallback(async () => {
|
const confirmDeletePlace = useCallback(async () => {
|
||||||
if (!deletePlaceId) return
|
if (!deletePlaceId) return
|
||||||
try {
|
try {
|
||||||
await tripStore.deletePlace(tripId, deletePlaceId)
|
await tripActions.deletePlace(tripId, deletePlaceId)
|
||||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||||
toast.success(t('trip.toast.placeDeleted'))
|
toast.success(t('trip.toast.placeDeleted'))
|
||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||||
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
|
}, [deletePlaceId, tripId, toast, selectedPlaceId])
|
||||||
|
|
||||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||||
const target = dayId || selectedDayId
|
const target = dayId || selectedDayId
|
||||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||||
try {
|
try {
|
||||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
await tripActions.assignPlaceToDay(tripId, target, placeId, position)
|
||||||
toast.success(t('trip.toast.assignedToDay'))
|
toast.success(t('trip.toast.assignedToDay'))
|
||||||
updateRouteForDay(target)
|
updateRouteForDay(target)
|
||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||||
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
}, [selectedDayId, tripId, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||||
try {
|
try {
|
||||||
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||||
}
|
}
|
||||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||||
}, [tripId, tripStore, toast, updateRouteForDay])
|
}, [tripId, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleReorder = useCallback((dayId, orderedIds) => {
|
const handleReorder = useCallback((dayId, orderedIds) => {
|
||||||
try {
|
try {
|
||||||
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
|
tripActions.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
|
||||||
// Update route immediately from orderedIds
|
// Update route immediately from orderedIds
|
||||||
const dayItems = tripStore.assignments[String(dayId)] || []
|
const dayItems = useTripStore.getState().assignments[String(dayId)] || []
|
||||||
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||||
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||||
@@ -251,17 +312,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
setRouteInfo(null)
|
setRouteInfo(null)
|
||||||
}
|
}
|
||||||
catch { toast.error(t('trip.toast.reorderError')) }
|
catch { toast.error(t('trip.toast.reorderError')) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, toast])
|
||||||
|
|
||||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||||
try { await tripStore.updateDayTitle(tripId, dayId, title) }
|
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
||||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, toast])
|
||||||
|
|
||||||
const handleSaveReservation = async (data) => {
|
const handleSaveReservation = async (data) => {
|
||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
if (data.type === 'hotel') {
|
if (data.type === 'hotel') {
|
||||||
@@ -269,7 +330,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
} else {
|
} else {
|
||||||
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success(t('trip.toast.reservationAdded'))
|
toast.success(t('trip.toast.reservationAdded'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
// Refresh accommodations if hotel was created
|
// Refresh accommodations if hotel was created
|
||||||
@@ -283,7 +344,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
const handleDeleteReservation = async (id) => {
|
const handleDeleteReservation = async (id) => {
|
||||||
try {
|
try {
|
||||||
await tripStore.deleteReservation(tripId, id)
|
await tripActions.deleteReservation(tripId, id)
|
||||||
toast.success(t('trip.toast.deleted'))
|
toast.success(t('trip.toast.deleted'))
|
||||||
// Refresh accommodations in case a hotel booking was deleted
|
// Refresh accommodations in case a hotel booking was deleted
|
||||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
@@ -320,12 +381,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
||||||
|
|
||||||
if (isLoading) {
|
// Splash screen — show for initial load + a brief moment for photos to start loading
|
||||||
|
const [splashDone, setSplashDone] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && trip) {
|
||||||
|
const timer = setTimeout(() => setSplashDone(true), 1500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isLoading, trip])
|
||||||
|
|
||||||
|
if (isLoading || !splashDone) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
<div style={{ width: 32, height: 32, border: '3px solid rgba(0,0,0,0.1)', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
background: 'var(--bg-primary)', ...fontStyle,
|
||||||
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
|
}}>
|
||||||
|
<style>{`
|
||||||
|
@keyframes planeFloat {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(-2deg); }
|
||||||
|
50% { transform: translateY(-12px) rotate(2deg); }
|
||||||
|
}
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div style={{ animation: 'planeFloat 2.5s ease-in-out infinite', marginBottom: 28 }}>
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.8 }}>
|
||||||
|
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.3px', marginBottom: 6, animation: 'fadeInUp 0.5s ease-out' }}>
|
||||||
|
{trip?.title || 'TREK'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontWeight: 500, letterSpacing: '2px', textTransform: 'uppercase', marginBottom: 32, animation: 'fadeInUp 0.5s ease-out 0.1s both' }}>
|
||||||
|
{t('trip.loadingPhotos')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%', background: 'var(--text-muted)',
|
||||||
|
animation: `dotPulse 1.4s ease-in-out ${i * 0.2}s infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -440,13 +542,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
accommodations={tripAccommodations}
|
accommodations={tripAccommodations}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
|
onExpandedDaysChange={setExpandedDayIds}
|
||||||
/>
|
/>
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -544,13 +647,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
lng={geoPlace?.lng}
|
lng={geoPlace?.lng}
|
||||||
onClose={() => setShowDayDetail(null)}
|
onClose={() => setShowDayDetail(null)}
|
||||||
onAccommodationChange={loadAccommodations}
|
onAccommodationChange={loadAccommodations}
|
||||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{selectedPlace && (
|
{selectedPlace && !isMobile && (
|
||||||
<PlaceInspector
|
<PlaceInspector
|
||||||
place={selectedPlace}
|
place={selectedPlace}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
@@ -561,7 +664,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
// When editing from assignment context, use assignment-level times
|
|
||||||
if (selectedAssignmentId) {
|
if (selectedAssignmentId) {
|
||||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
@@ -576,7 +678,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
files={files}
|
files={files}
|
||||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||||
try {
|
try {
|
||||||
@@ -591,12 +693,64 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}))
|
}))
|
||||||
} catch {}
|
} catch {}
|
||||||
}}
|
}}
|
||||||
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedPlace && isMobile && ReactDOM.createPortal(
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||||
|
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<PlaceInspector
|
||||||
|
place={selectedPlace}
|
||||||
|
categories={categories}
|
||||||
|
days={days}
|
||||||
|
selectedDayId={selectedDayId}
|
||||||
|
selectedAssignmentId={selectedAssignmentId}
|
||||||
|
assignments={assignments}
|
||||||
|
reservations={reservations}
|
||||||
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
|
onEdit={() => {
|
||||||
|
if (selectedAssignmentId) {
|
||||||
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
|
setEditingPlace(placeWithAssignmentTimes)
|
||||||
|
} else {
|
||||||
|
setEditingPlace(selectedPlace)
|
||||||
|
}
|
||||||
|
setEditingAssignmentId(selectedAssignmentId || null)
|
||||||
|
setShowPlaceForm(true)
|
||||||
|
setSelectedPlaceId(null)
|
||||||
|
}}
|
||||||
|
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||||
|
onAssignToDay={handleAssignToDay}
|
||||||
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
|
files={files}
|
||||||
|
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||||
|
try {
|
||||||
|
const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
|
||||||
|
useTripStore.setState(state => ({
|
||||||
|
assignments: {
|
||||||
|
...state.assignments,
|
||||||
|
[String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
|
||||||
|
a.id === assignmentId ? { ...a, participants: data.participants } : a
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||||
|
leftWidth={0}
|
||||||
|
rightWidth={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{mobileSidebarOpen && ReactDOM.createPortal(
|
{mobileSidebarOpen && ReactDOM.createPortal(
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
|
||||||
<div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
||||||
@@ -608,8 +762,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,9 +805,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||||
onDelete={(id) => tripStore.deleteFile(tripId, id)}
|
onDelete={(id) => tripActions.deleteFile(tripId, id)}
|
||||||
onUpdate={(id, data) => tripStore.loadFiles(tripId)}
|
onUpdate={(id, data) => tripActions.loadFiles(tripId)}
|
||||||
places={places}
|
places={places}
|
||||||
days={days}
|
days={days}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -677,10 +831,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { mapsApi } from '../api/client'
|
||||||
|
|
||||||
|
// Shared photo cache — used by PlaceAvatar (sidebar) and MapView (map markers)
|
||||||
|
interface PhotoEntry {
|
||||||
|
photoUrl: string | null
|
||||||
|
thumbDataUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, PhotoEntry>()
|
||||||
|
const inFlight = new Set<string>()
|
||||||
|
const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
|
||||||
|
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
|
||||||
|
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
|
||||||
|
|
||||||
|
function notify(key: string, entry: PhotoEntry) {
|
||||||
|
listeners.get(key)?.forEach(fn => fn(entry))
|
||||||
|
listeners.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyThumb(key: string, thumb: string) {
|
||||||
|
thumbListeners.get(key)?.forEach(fn => fn(thumb))
|
||||||
|
thumbListeners.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPhotoLoaded(key: string, fn: (entry: PhotoEntry) => void): () => void {
|
||||||
|
if (!listeners.has(key)) listeners.set(key, new Set())
|
||||||
|
listeners.get(key)!.add(fn)
|
||||||
|
return () => { listeners.get(key)?.delete(fn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to thumb availability — called when base64 thumb is ready (may be after photoUrl)
|
||||||
|
export function onThumbReady(key: string, fn: (thumb: string) => void): () => void {
|
||||||
|
if (!thumbListeners.has(key)) thumbListeners.set(key, new Set())
|
||||||
|
thumbListeners.get(key)!.add(fn)
|
||||||
|
return () => { thumbListeners.get(key)?.delete(fn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCached(key: string): PhotoEntry | undefined {
|
||||||
|
return cache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoading(key: string): boolean {
|
||||||
|
return inFlight.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert image URL to base64 via canvas (CORS required — Wikimedia supports it)
|
||||||
|
export function urlToBase64(url: string, size: number = 48): Promise<string | null> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = size
|
||||||
|
canvas.height = size
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const s = Math.min(img.naturalWidth, img.naturalHeight)
|
||||||
|
const sx = (img.naturalWidth - s) / 2
|
||||||
|
const sy = (img.naturalHeight - s) / 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
|
||||||
|
ctx.clip()
|
||||||
|
ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size)
|
||||||
|
resolve(canvas.toDataURL('image/webp', 0.6))
|
||||||
|
} catch { resolve(null) }
|
||||||
|
}
|
||||||
|
img.onerror = () => resolve(null)
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPhoto(
|
||||||
|
cacheKey: string,
|
||||||
|
photoId: string,
|
||||||
|
lat?: number,
|
||||||
|
lng?: number,
|
||||||
|
name?: string,
|
||||||
|
callback?: (entry: PhotoEntry) => void
|
||||||
|
) {
|
||||||
|
const cached = cache.get(cacheKey)
|
||||||
|
if (cached) { callback?.(cached); return }
|
||||||
|
|
||||||
|
if (inFlight.has(cacheKey)) {
|
||||||
|
if (callback) onPhotoLoaded(cacheKey, callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.add(cacheKey)
|
||||||
|
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||||
|
.then(async (data: { photoUrl?: string }) => {
|
||||||
|
const photoUrl = data.photoUrl || null
|
||||||
|
if (!photoUrl) {
|
||||||
|
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||||
|
cache.set(cacheKey, entry)
|
||||||
|
callback?.(entry)
|
||||||
|
notify(cacheKey, entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store URL first — sidebar can show immediately
|
||||||
|
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||||
|
cache.set(cacheKey, entry)
|
||||||
|
callback?.(entry)
|
||||||
|
notify(cacheKey, entry)
|
||||||
|
|
||||||
|
// Generate base64 thumb in background
|
||||||
|
const thumb = await urlToBase64(photoUrl)
|
||||||
|
if (thumb) {
|
||||||
|
entry.thumbDataUrl = thumb
|
||||||
|
notifyThumb(cacheKey, thumb)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||||
|
cache.set(cacheKey, entry)
|
||||||
|
callback?.(entry)
|
||||||
|
notify(cacheKey, entry)
|
||||||
|
})
|
||||||
|
.finally(() => { inFlight.delete(cacheKey) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllThumbs(): Record<string, string> {
|
||||||
|
const r: Record<string, string> = {}
|
||||||
|
for (const [k, v] of cache.entries()) {
|
||||||
|
if (v.thumbDataUrl) r[k] = v.thumbDataUrl
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { addonsApi } from '../api/client'
|
||||||
|
|
||||||
|
interface Addon {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
icon: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddonState {
|
||||||
|
addons: Addon[]
|
||||||
|
loaded: boolean
|
||||||
|
loadAddons: () => Promise<void>
|
||||||
|
isEnabled: (id: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAddonStore = create<AddonState>((set, get) => ({
|
||||||
|
addons: [],
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
loadAddons: async () => {
|
||||||
|
try {
|
||||||
|
const data = await addonsApi.enabled()
|
||||||
|
set({ addons: data.addons || [], loaded: true })
|
||||||
|
} catch {
|
||||||
|
set({ loaded: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled: (id: string) => {
|
||||||
|
return get().addons.some(a => a.id === id && a.enabled)
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -17,19 +17,22 @@ interface AvatarResponse {
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
token: string | null
|
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
hasMapsKey: boolean
|
hasMapsKey: boolean
|
||||||
serverTimezone: string
|
serverTimezone: string
|
||||||
|
/** Server policy: all users must enable MFA */
|
||||||
|
appRequireMfa: boolean
|
||||||
|
tripRemindersEnabled: boolean
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
loadUser: () => Promise<void>
|
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||||
|
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||||
updateMapsKey: (key: string | null) => Promise<void>
|
updateMapsKey: (key: string | null) => Promise<void>
|
||||||
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
|
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
|
||||||
updateProfile: (profileData: Partial<User>) => Promise<void>
|
updateProfile: (profileData: Partial<User>) => Promise<void>
|
||||||
@@ -38,18 +41,21 @@ interface AuthState {
|
|||||||
setDemoMode: (val: boolean) => void
|
setDemoMode: (val: boolean) => void
|
||||||
setHasMapsKey: (val: boolean) => void
|
setHasMapsKey: (val: boolean) => void
|
||||||
setServerTimezone: (tz: string) => void
|
setServerTimezone: (tz: string) => void
|
||||||
|
setAppRequireMfa: (val: boolean) => void
|
||||||
|
setTripRemindersEnabled: (val: boolean) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: localStorage.getItem('auth_token') || null,
|
isAuthenticated: false,
|
||||||
isAuthenticated: !!localStorage.getItem('auth_token'),
|
isLoading: true,
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
hasMapsKey: false,
|
hasMapsKey: false,
|
||||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
appRequireMfa: false,
|
||||||
|
tripRemindersEnabled: false,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@@ -59,15 +65,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: false, error: null })
|
set({ isLoading: false, error: null })
|
||||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||||
}
|
}
|
||||||
localStorage.setItem('auth_token', data.token)
|
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.token,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
connect(data.token)
|
connect()
|
||||||
return data as AuthResponse
|
return data as AuthResponse
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = getApiErrorMessage(err, 'Login failed')
|
const error = getApiErrorMessage(err, 'Login failed')
|
||||||
@@ -80,15 +84,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||||
localStorage.setItem('auth_token', data.token)
|
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.token,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
connect(data.token)
|
connect()
|
||||||
return data as AuthResponse
|
return data as AuthResponse
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = getApiErrorMessage(err, 'Verification failed')
|
const error = getApiErrorMessage(err, 'Verification failed')
|
||||||
@@ -101,15 +103,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.register({ username, email, password, invite_token })
|
const data = await authApi.register({ username, email, password, invite_token })
|
||||||
localStorage.setItem('auth_token', data.token)
|
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.token,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
connect(data.token)
|
connect()
|
||||||
return data
|
return data
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = getApiErrorMessage(err, 'Registration failed')
|
const error = getApiErrorMessage(err, 'Registration failed')
|
||||||
@@ -120,22 +120,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
disconnect()
|
disconnect()
|
||||||
localStorage.removeItem('auth_token')
|
// Tell server to clear the httpOnly cookie
|
||||||
|
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
|
// Clear service worker caches containing sensitive data
|
||||||
|
if ('caches' in window) {
|
||||||
|
caches.delete('api-data').catch(() => {})
|
||||||
|
caches.delete('user-uploads').catch(() => {})
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
loadUser: async () => {
|
loadUser: async (opts?: { silent?: boolean }) => {
|
||||||
const token = get().token
|
const silent = !!opts?.silent
|
||||||
if (!token) {
|
if (!silent) set({ isLoading: true })
|
||||||
set({ isLoading: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
set({ isLoading: true })
|
|
||||||
try {
|
try {
|
||||||
const data = await authApi.me()
|
const data = await authApi.me()
|
||||||
set({
|
set({
|
||||||
@@ -143,15 +144,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
connect(token)
|
connect()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
localStorage.removeItem('auth_token')
|
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||||
set({
|
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||||
user: null,
|
(err as { response?: { status?: number } }).response?.status === 401
|
||||||
token: null,
|
if (isAuthError) {
|
||||||
isAuthenticated: false,
|
set({
|
||||||
isLoading: false,
|
user: null,
|
||||||
})
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
set({ isLoading: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -205,21 +211,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
|
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||||
|
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.demoLogin()
|
const data = await authApi.demoLogin()
|
||||||
localStorage.setItem('auth_token', data.token)
|
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.token,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
demoMode: true,
|
demoMode: true,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
connect(data.token)
|
connect()
|
||||||
return data
|
return data
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = getApiErrorMessage(err, 'Demo login failed')
|
const error = getApiErrorMessage(err, 'Demo login failed')
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { useAuthStore } from './authStore'
|
||||||
|
|
||||||
|
export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody'
|
||||||
|
|
||||||
|
/** Minimal trip shape used by permission checks — accepts both Trip and DashboardTrip */
|
||||||
|
type TripOwnerContext = { user_id?: unknown; owner_id?: unknown; is_owner?: unknown }
|
||||||
|
|
||||||
|
interface PermissionsState {
|
||||||
|
permissions: Record<string, PermissionLevel>
|
||||||
|
setPermissions: (perms: Record<string, PermissionLevel>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePermissionsStore = create<PermissionsState>((set) => ({
|
||||||
|
permissions: {},
|
||||||
|
setPermissions: (perms) => set({ permissions: perms }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that returns a permission checker bound to the current user.
|
||||||
|
* Usage: const can = useCanDo(); can('trip_create') or can('file_upload', trip)
|
||||||
|
*/
|
||||||
|
export function useCanDo() {
|
||||||
|
const perms = usePermissionsStore((s: PermissionsState) => s.permissions)
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
|
||||||
|
return function can(
|
||||||
|
actionKey: string,
|
||||||
|
trip?: TripOwnerContext | null,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.role === 'admin') return true
|
||||||
|
|
||||||
|
const level = perms[actionKey]
|
||||||
|
if (!level) return true // not configured = allow
|
||||||
|
|
||||||
|
// Support both Trip (owner_id) and DashboardTrip/server response (user_id)
|
||||||
|
const tripOwnerId = (trip?.user_id as number | undefined) ?? (trip?.owner_id as number | undefined) ?? null
|
||||||
|
const isOwnerFlag = trip?.is_owner === true || trip?.is_owner === 1
|
||||||
|
const isOwner = isOwnerFlag || (tripOwnerId !== null && tripOwnerId === user.id)
|
||||||
|
const isMember = !isOwner && trip != null
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'admin': return false
|
||||||
|
case 'trip_owner': return isOwner
|
||||||
|
case 'trip_member': return isOwner || isMember
|
||||||
|
case 'everybody': return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -37,15 +37,22 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
|||||||
updatePlace: async (tripId, placeId, placeData) => {
|
updatePlace: async (tripId, placeId, placeData) => {
|
||||||
try {
|
try {
|
||||||
const data = await placesApi.update(tripId, placeId, placeData)
|
const data = await placesApi.update(tripId, placeId, placeData)
|
||||||
set(state => ({
|
set(state => {
|
||||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
const updatedAssignments = { ...state.assignments }
|
||||||
assignments: Object.fromEntries(
|
let changed = false
|
||||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||||
dayId,
|
if (items.some((a: Assignment) => a.place?.id === placeId)) {
|
||||||
items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
updatedAssignments[dayId] = items.map((a: Assignment) =>
|
||||||
])
|
a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a
|
||||||
),
|
)
|
||||||
}))
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||||
|
...(changed ? { assignments: updatedAssignments } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
return data.place
|
return data.place
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(getApiErrorMessage(err, 'Error updating place'))
|
throw new Error(getApiErrorMessage(err, 'Error updating place'))
|
||||||
@@ -55,15 +62,20 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
|||||||
deletePlace: async (tripId, placeId) => {
|
deletePlace: async (tripId, placeId) => {
|
||||||
try {
|
try {
|
||||||
await placesApi.delete(tripId, placeId)
|
await placesApi.delete(tripId, placeId)
|
||||||
set(state => ({
|
set(state => {
|
||||||
places: state.places.filter(p => p.id !== placeId),
|
const updatedAssignments = { ...state.assignments }
|
||||||
assignments: Object.fromEntries(
|
let changed = false
|
||||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||||
dayId,
|
if (items.some((a: Assignment) => a.place?.id === placeId)) {
|
||||||
items.filter((a: Assignment) => a.place?.id !== placeId)
|
updatedAssignments[dayId] = items.filter((a: Assignment) => a.place?.id !== placeId)
|
||||||
])
|
changed = true
|
||||||
),
|
}
|
||||||
}))
|
}
|
||||||
|
return {
|
||||||
|
places: state.places.filter(p => p.id !== placeId),
|
||||||
|
...(changed ? { assignments: updatedAssignments } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface User {
|
|||||||
created_at: string
|
created_at: string
|
||||||
/** Present after load; true when TOTP MFA is enabled for password login */
|
/** Present after load; true when TOTP MFA is enabled for password login */
|
||||||
mfa_enabled?: boolean
|
mfa_enabled?: boolean
|
||||||
|
/** True when a password change is required before the user can continue */
|
||||||
|
must_change_password?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Trip {
|
export interface Trip {
|
||||||
@@ -20,6 +22,7 @@ export interface Trip {
|
|||||||
end_date: string
|
end_date: string
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
is_archived: boolean
|
is_archived: boolean
|
||||||
|
reminder_days: number
|
||||||
owner_id: number
|
owner_id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -49,6 +52,7 @@ export interface Place {
|
|||||||
image_url: string | null
|
image_url: string | null
|
||||||
google_place_id: string | null
|
google_place_id: string | null
|
||||||
osm_id: string | null
|
osm_id: string | null
|
||||||
|
route_geometry: string | null
|
||||||
place_time: string | null
|
place_time: string | null
|
||||||
end_time: string | null
|
end_time: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -106,6 +110,7 @@ export interface BudgetItem {
|
|||||||
paid_by: number | null
|
paid_by: number | null
|
||||||
persons: number
|
persons: number
|
||||||
members: BudgetMember[]
|
members: BudgetMember[]
|
||||||
|
expense_date: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetMember {
|
export interface BudgetMember {
|
||||||
@@ -281,6 +286,8 @@ export interface AppConfig {
|
|||||||
has_maps_key?: boolean
|
has_maps_key?: boolean
|
||||||
allowed_file_types?: string
|
allowed_file_types?: string
|
||||||
timezone?: string
|
timezone?: string
|
||||||
|
/** When true, users without MFA cannot use the app until they enable it */
|
||||||
|
require_mfa?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translation function type
|
// Translation function type
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
// Carto map tiles (default provider)
|
// Carto map tiles (default provider)
|
||||||
@@ -45,23 +45,24 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// API calls — prefer network, fall back to cache
|
// API calls — prefer network, fall back to cache
|
||||||
urlPattern: /\/api\/.*/i,
|
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||||
|
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-data',
|
cacheName: 'api-data',
|
||||||
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||||
networkTimeoutSeconds: 5,
|
networkTimeoutSeconds: 5,
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Uploaded files (photos, covers, documents)
|
// Uploaded files (photos, covers — public assets only)
|
||||||
urlPattern: /\/uploads\/.*/i,
|
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'user-uploads',
|
cacheName: 'user-uploads',
|
||||||
expiration: { maxEntries: 300, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -87,6 +88,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
build: {
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -101,6 +105,10 @@ export default defineConfig({
|
|||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
ws: true,
|
ws: true,
|
||||||
|
},
|
||||||
|
'/mcp': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
services:
|
services:
|
||||||
init-permissions:
|
|
||||||
image: alpine:3.20
|
|
||||||
container_name: trek-init-permissions
|
|
||||||
user: "0:0"
|
|
||||||
command: >
|
|
||||||
sh -c "mkdir -p /app/data /app/uploads &&
|
|
||||||
chown -R 1000:1000 /app/data /app/uploads &&
|
|
||||||
chmod -R u+rwX /app/data /app/uploads"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/trek:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
container_name: trek
|
||||||
depends_on:
|
read_only: true
|
||||||
init-permissions:
|
security_opt:
|
||||||
condition: service_completed_successfully
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:noexec,nosuid,size=64m
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- JWT_SECRET=${JWT_SECRET:-}
|
|
||||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- TZ=${TZ:-UTC}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||||
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
|
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||||
|
- TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP)
|
||||||
|
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
|
- OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
|
- OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
|
- OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||||
|
- OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||||
|
- OIDC_ONLY=false # Set true to disable local password auth entirely (SSO only)
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,4 +1,27 @@
|
|||||||
PORT=3001
|
PORT=3001 # Port to run the server on
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
NODE_ENV=development # development = development mode; production = production mode
|
||||||
NODE_ENV=development
|
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
|
||||||
DEBUG=false
|
# Auto-generated and persisted to ./data/.encryption_key if not set.
|
||||||
|
# Upgrade from a version that used JWT_SECRET for encryption: set to your old JWT_SECRET value so
|
||||||
|
# existing encrypted data remains readable, then re-save credentials via the admin panel.
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
|
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||||
|
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
|
||||||
|
TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||||
|
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||||
|
|
||||||
|
APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
|
||||||
|
|
||||||
|
OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
|
OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
|
OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||||
|
OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||||
|
OIDC_ONLY=true # Disable local password auth entirely (SSO only)
|
||||||
|
OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||||
|
OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||||
|
OIDC_DISCOVERY_URL= # Override the auto-constructed discovery endpoint (e.g. Authentik: https://auth.example.com/application/o/trek/.well-known/openid-configuration)
|
||||||
|
|
||||||
|
DEMO_MODE=false # Demo mode - resets data hourly
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.7.0",
|
"version": "2.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.7.0",
|
"version": "2.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
@@ -26,12 +28,14 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
@@ -462,6 +466,358 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hono/node-server": {
|
||||||
|
"version": "1.19.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||||
|
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.14.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
|
"version": "1.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
|
||||||
|
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.9",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.5",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"eventsource-parser": "^3.0.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"hono": "^4.11.4",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"json-schema-typed": "^8.0.2",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"zod": "^3.25 || ^4.0",
|
||||||
|
"zod-to-json-schema": "^3.25.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@cfworker/json-schema": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"negotiator": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "^3.1.2",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"qs": "^6.14.1",
|
||||||
|
"raw-body": "^3.0.1",
|
||||||
|
"type-is": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "^2.0.0",
|
||||||
|
"body-parser": "^2.2.1",
|
||||||
|
"content-disposition": "^1.0.0",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cookie": "^0.7.1",
|
||||||
|
"cookie-signature": "^1.2.1",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"finalhandler": "^2.1.0",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"merge-descriptors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"once": "^1.4.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"proxy-addr": "^2.0.7",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"router": "^2.2.0",
|
||||||
|
"send": "^1.1.0",
|
||||||
|
"serve-static": "^2.2.0",
|
||||||
|
"statuses": "^2.0.1",
|
||||||
|
"type-is": "^2.0.1",
|
||||||
|
"vary": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.7.0",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.1",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"statuses": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"send": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@otplib/core": {
|
"node_modules/@otplib/core": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||||
@@ -560,6 +916,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cors": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -772,6 +1138,39 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ajv": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1326,6 +1725,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -1380,6 +1798,20 @@
|
|||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1655,6 +2087,27 @@
|
|||||||
"bare-events": "^2.7.0"
|
"bare-events": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expand-template": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -1710,12 +2163,52 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||||
|
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "10.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -2018,6 +2511,15 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hono": {
|
||||||
|
"version": "4.12.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||||
|
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -2100,6 +2602,15 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -2164,12 +2675,45 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/json-schema-typed": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/jsonfile": {
|
"node_modules/jsonfile": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||||
@@ -2711,6 +3255,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -2730,6 +3283,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
@@ -2945,6 +3507,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-main-filename": {
|
"node_modules/require-main-filename": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
@@ -2960,6 +3531,55 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/router": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"is-promise": "^4.0.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"path-to-regexp": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/router/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/router/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/router/node_modules/path-to-regexp": {
|
||||||
|
"version": "8.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||||
|
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -3055,6 +3675,27 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -3535,6 +4176,21 @@
|
|||||||
"webidl-conversions": "^3.0.0"
|
"webidl-conversions": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which-module": {
|
"node_modules/which-module": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
@@ -3636,6 +4292,24 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.25.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||||
|
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.28 || ^4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
"dev": "tsx watch src/index.ts"
|
"dev": "tsx watch src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
@@ -25,12 +27,14 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="icon">
|
||||||
|
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(56,51) scale(0.267)">
|
||||||
|
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>TREK</title>
|
||||||
|
|
||||||
|
<!-- PWA / iOS -->
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Leaflet -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="" />
|
||||||
|
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |