Compare commits

...

203 Commits

Author SHA1 Message Date
Maurice 71403e6303 fix: always fetch fresh photo URLs for map markers instead of using stored HTTP URLs 2026-04-01 19:48:58 +02:00
Maurice 43fc4db00e fix: convert stored HTTP photo URLs to base64 for map markers, add exchangerate-api to CSP 2026-04-01 19:40:19 +02:00
Maurice e9ee2d4b0d fix: rebuild client assets with photoService and latest changes 2026-04-01 19:32:45 +02:00
Maurice 228cb05932 chore: bump version to 2.7.2 2026-04-01 19:13:32 +02:00
Maurice 411d5408c1 fix: place inspector too narrow at intermediate window widths (#272)
Inspector now ignores sidebar widths when window is under 900px,
preventing it from being squeezed when sidebars are visually hidden
but their width values are still set.
2026-04-01 17:58:57 +02:00
Maurice 45684d9e44 Merge pull request #257 from jubnl/dev
Security hardening, encryption at rest
2026-04-01 17:42:43 +02:00
jubnl 0ebcff9504 Conflict resolution 2026-04-01 17:40:45 +02:00
Julien G. edafe01387 Merge branch 'dev' into dev 2026-04-01 17:30:31 +02:00
Maurice 16277a3811 security: fix missing trip access checks on Immich routes (GHSA-pcr3-6647-jh72)
security: require auth for uploaded photos (GHSA-wxx3-84fc-mrx2)

GHSA-pcr3-6647-jh72 (HIGH):
- Add canAccessTrip check to all /trips/:tripId/photos and
  /trips/:tripId/album-links endpoints
- Prevents authenticated users from accessing other trips' photos

GHSA-wxx3-84fc-mrx2 (LOW):
- /uploads/photos now requires JWT auth token or valid share token
- Covers and avatars remain public (needed for login/share pages)
- Files were already blocked behind auth
2026-04-01 15:46:08 +02:00
Maurice ef5b381f8e feat: collapse days hides map markers, Immich test-before-save (#216)
Map markers:
- Collapsing a day in the sidebar hides its places from the map
- Places assigned to multiple days only hide when all days collapsed
- Unplanned places always stay visible

Immich settings:
- New POST /integrations/immich/test endpoint validates credentials
  without saving them
- Save button disabled until test connection passes
- Changing URL or API key resets test status
- i18n: testFirst key for all 12 languages
2026-04-01 15:30:59 +02:00
Maurice ef9880a2a5 feat: Immich album linking with auto-sync (#206)
- Link Immich albums to trips — photos sync automatically
- Album picker shows all user's Immich albums
- Linked albums displayed as chips with sync/unlink buttons
- Auto-sync on link: fetches all album photos and adds to trip
- Manual re-sync button for each linked album
- DB migration: trip_album_links table

fix: shared Immich photos visible to other trip members

- Thumbnail/original proxy now uses photo owner's Immich credentials
  when userId query param is provided, fixing 404 for shared photos
- i18n: album keys for all 12 languages
2026-04-01 15:21:20 +02:00
Maurice 95cb81b0e5 perf: major trip planner performance overhaul (#218)
Store & re-render optimization:
- TripPlannerPage uses selective Zustand selectors instead of full store
- placesSlice only updates affected days on place update/delete
- Route calculation only reacts to selected day's assignments
- DayPlanSidebar uses stable action refs instead of full store

Map marker performance:
- Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests)
- Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia)
- Map markers use base64 data URL <img> tags for smooth zoom (no external image decode)
- Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading
- Icon cache prevents duplicate L.divIcon creation
- MarkerClusterGroup with animate:false and optimized chunk settings
- Photo fetch deduplication and batched state updates

Server optimizations:
- Wikimedia image size reduced to 400px (from 600px)
- Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching
- Removed unused image-proxy endpoint

UX improvements:
- Splash screen with plane animation during initial photo preload
- Markdown rendering in DayPlanSidebar place descriptions
- Missing i18n keys added, all 12 languages synced to 1376 keys
2026-04-01 14:56:01 +02:00
Maurice 7d0ae631b8 fix: mobile place editing and detail view (#269)
- PlacesSidebar mobile: tap opens action sheet with view details,
  edit, assign to day, and delete options
- PlaceInspector renders as fullscreen portal overlay on mobile
- DayPlanSidebar mobile: tapping a place closes overlay and opens
  inspector
- Inspector closes when edit or delete is triggered on mobile
- i18n: added places.viewDetails for all 12 languages
2026-04-01 12:38:44 +02:00
Maurice 5c04074d54 fix: allow unauthenticated SMTP by saving empty user/pass fields (#265)
The test-smtp button filtered out empty SMTP user/password values
before saving, preventing unauthenticated SMTP setups from working.
Changed filter from truthy check to !== undefined so empty strings
are properly persisted.
2026-04-01 12:20:03 +02:00
Maurice e89ba2ecfc fix: add referrerPolicy to TileLayer to fix OSM tile blocking (#264)
OpenStreetMap requires a Referer header per their tile usage policy.
Without it, tiles are blocked with "Access blocked" error.
2026-04-01 12:17:53 +02:00
Maurice 4ebf9c5f11 feat: add expense date and CSV export to budget
- New expense_date column on budget items (DB migration #42)
- Date column in budget table with custom date picker
- CSV export button with BOM, semicolon separator, localized dates,
  currency in header, per-person/day calculations
- CustomDatePicker compact/borderless modes for inline table use
- i18n keys for all 12 languages
2026-04-01 12:16:11 +02:00
jubnl add0b17e04 feat(auth): migrate JWT storage from localStorage to httpOnly cookies
Eliminates XSS token theft risk by storing session JWTs in an httpOnly
cookie (trek_session) instead of localStorage, making them inaccessible
to JavaScript entirely.

- Add cookie-parser middleware and setAuthCookie/clearAuthCookie helpers
- Set trek_session cookie on login, register, demo-login, MFA verify, OIDC exchange
- Auth middleware reads cookie first, falls back to Authorization: Bearer (MCP unchanged)
- Add POST /api/auth/logout to clear the cookie server-side
- Remove all localStorage auth_token reads/writes from client
- Axios uses withCredentials; raw fetch calls use credentials: include
- WebSocket ws-token exchange uses credentials: include (no JWT param)
- authStore initialises isLoading: true so ProtectedRoute waits for /api/auth/me

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:02:45 +02:00
Maurice 60906cf1d1 fix: hide MCP tokens tab when addon inactive, move permissions to users tab
- MCP tokens tab only shown when MCP addon is enabled
- Permissions panel moved from own tab to users tab below invite links
- Fixed inconsistent dropdown widths in permissions panel
2026-04-01 10:39:43 +02:00
Julien G. 9292acb979 Merge branch 'dev' into dev 2026-04-01 10:27:51 +02:00
Maurice be57b7130f feat: render markdown in place descriptions, notes and reservations
Use react-markdown with remark-gfm for place description/notes
in PlaceInspector and day note subtitles and reservation notes
in DayPlanSidebar. Reuses existing collab-note-md CSS styles.
2026-04-01 10:19:59 +02:00
jubnl b88a8fcbb5 fix: unify password validation error to show all requirements at once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:14:35 +02:00
Maurice 040840917c feat: add Google Maps list import
Import places from shared Google Maps lists via URL.
Button in places sidebar next to GPX import opens a modal
where users can paste a shared list link. Server fetches
list data from Google Maps and creates places with name,
coordinates and notes. i18n keys added for all 12 languages.

Closes #205
2026-04-01 10:13:35 +02:00
jubnl 44e5f07f59 fix: persist encryption key to disk regardless of resolution source
Previously, when the JWT secret was used as a fallback encryption key,
nothing was written to data/.encryption_key. This meant that rotating
the JWT secret via the admin panel would silently break decryption of
all stored secrets on the next restart.

Now, whatever key is resolved — env var, JWT secret fallback, or
auto-generated — is immediately persisted to data/.encryption_key.
On all subsequent starts, the file is read directly and the fallback
chain is skipped entirely, making JWT rotation permanently safe.

The env var path also writes to the file so the key survives container
restarts if the env var is later removed.
2026-04-01 10:03:46 +02:00
jubnl c9e61859ce chore(helm): update ENCRYPTION_KEY docs to reflect automatic fallback
Existing installs no longer need to manually set ENCRYPTION_KEY to their
old JWT secret on upgrade — the server falls back to data/.jwt_secret
automatically. Update values.yaml, NOTES.txt, and chart README accordingly.
2026-04-01 09:50:38 +02:00
jubnl 862f59b77a chore: update docker-compose ENCRYPTION_KEY comment to match new behaviour 2026-04-01 09:50:28 +02:00
jubnl 871bfd7dfd fix: make ENCRYPTION_KEY optional with backwards-compatible fallback
process.exit(1) when ENCRYPTION_KEY is unset was a breaking change for
existing installs — a plain git pull would prevent the server from
starting.

Replace with a three-step fallback:
  1. ENCRYPTION_KEY env var (explicit, takes priority)
  2. data/.jwt_secret (existing installs: encrypted data stays readable
     after upgrade with zero manual intervention)
  3. data/.encryption_key auto-generated on first start (fresh installs)

A warning is logged when falling back to the JWT secret so operators
are nudged toward setting ENCRYPTION_KEY explicitly.

Update README env table and Docker Compose comment to reflect that
ENCRYPTION_KEY is recommended but no longer required.
2026-04-01 09:50:17 +02:00
jubnl 4d596f2ff9 feat: add encryption key migration script and document it in README
Add server/scripts/migrate-encryption.ts — a standalone script that
re-encrypts all at-rest secrets (OIDC client secret, SMTP password,
Maps/OpenWeather/Immich API keys, MFA secrets) when rotating
ENCRYPTION_KEY, without requiring the app to be running.

- Prompts for old and new keys interactively; input is never echoed,
  handles copy-pasted keys correctly via a shared readline interface
  with a line queue to prevent race conditions on piped/pasted input
- Creates a timestamped DB backup before any changes
- Idempotent: detects already-migrated values by trying the new key
- Exits non-zero and retains the backup if any field fails

README updates:
- Add .env setup step (openssl rand -hex 32) before the Docker Compose
  snippet so ENCRYPTION_KEY is set before first start
- Add ENCRYPTION_KEY to the docker run one-liner
- Add "Rotating the Encryption Key" section documenting the script,
  the docker exec command, and the upgrade path via ./data/.jwt_secret

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:35:32 +02:00
Maurice 8c85ea3644 fix: restrict trip listing and access to own/shared trips only (#250)
Remove admin override that returned all trips regardless of ownership.
Admins now see only their own trips and trips where they are a member.
2026-04-01 09:29:28 +02:00
jubnl 19350fbc3e fix: point upgraders to ./data/.jwt_secret in ENCRYPTION_KEY error and docs
The startup error now tells operators exactly where to find the old key
value (./data/.jwt_secret) rather than just saying "your old JWT_SECRET".
docker-compose.yml and README updated to mark ENCRYPTION_KEY as required
and remove the stale "auto-generated" comments.
2026-04-01 08:43:45 +02:00
jubnl 358afd2428 fix: require ENCRYPTION_KEY at startup instead of auto-generating
Auto-generating and persisting the key to data/.encryption_key co-locates
the key with the database, defeating encryption at rest if an attacker can
read the data directory. It also silently loses all encrypted secrets if the
data volume is recreated.

Replace the auto-generation fallback with a hard startup error that tells
operators exactly what to do:
- Upgraders from the JWT_SECRET-derived encryption era: set ENCRYPTION_KEY
  to their old JWT_SECRET so existing ciphertext remains readable.
- Fresh installs: generate a key with `openssl rand -hex 32`.
2026-04-01 08:43:36 +02:00
jubnl 7a314a92b1 fix: add SSRF protection for link preview and Immich URL
- Create server/src/utils/ssrfGuard.ts with checkSsrf() and createPinnedAgent()
  - Resolves DNS before allowing outbound requests to catch hostnames that
    map to private IPs (closes the TOCTOU gap in the old inline checks)
  - Always blocks loopback (127.x, ::1) and link-local/metadata (169.254.x)
  - RFC-1918, CGNAT (100.64/10), and IPv6 ULA ranges blocked by default;
    opt-in via ALLOW_INTERNAL_NETWORK=true for self-hosters running Immich
    on a local network
  - createPinnedAgent() pins node-fetch to the validated IP, preventing
    DNS rebinding between the check and the actual connection

- Replace isValidImmichUrl() (hostname-string check, no DNS resolution)
  with checkSsrf(); make PUT /integrations/immich/settings async
  - Audit log entry (immich.private_ip_configured) written when a user
    saves an Immich URL that resolves to a private IP
  - Response includes a warning field surfaced as a toast in the UI

- Replace ~20 lines of duplicated inline SSRF logic in the link-preview
  handler with a single checkSsrf() call + pinned agent

- Document ALLOW_INTERNAL_NETWORK in README, docker-compose.yml,
  server/.env.example, chart/values.yaml, chart/templates/configmap.yaml,
  and chart/README.md
2026-04-01 07:59:03 +02:00
jubnl e03505dca2 fix: enforce consistent password policy across all auth flows
Replace duplicated inline validation with a shared validatePassword()
utility that checks minimum length (8), rejects repetitive and common
passwords, and requires uppercase, lowercase, a digit, and a special
character.

- Add server/src/services/passwordPolicy.ts as single source of truth
- Apply to registration, password change, and admin create/edit user
  (admin routes previously had zero validation)
- Fix client min-length mismatch (6 vs 8) in RegisterPage and LoginPage
- Add client-side password length guard to AdminPage forms
- Update register.passwordTooShort and settings.passwordWeak i18n keys
  in all 12 locales to reflect the corrected requirements
2026-04-01 07:58:46 +02:00
jubnl ce8d498f2d fix: add independent rate limiter for MFA verification endpoints
TOTP brute-force is a realistic attack once a password is compromised:
with no independent throttle, an attacker shared the login budget (10
attempts) across /login, /register, and /mfa/verify-login, and
/mfa/enable had no rate limiting at all.

- Add a dedicated `mfaAttempts` store so MFA limits are tracked
  separately from login attempts
- Introduce `mfaLimiter` (5 attempts / 15 min) applied to both
  /mfa/verify-login and /mfa/enable
- Refactor `rateLimiter()` to accept an optional store parameter,
  keeping all existing call-sites unchanged
- Include mfaAttempts in the periodic cleanup interval
2026-04-01 07:58:29 +02:00
jubnl b109c1340a fix: prevent ICS header injection in calendar export
Three vulnerabilities patched in the /export.ics route:

- esc() now handles bare \r and CRLF sequences — the previous regex only
  matched \n, leaving \r intact and allowing CRLF injection via \r\n
- reservation DESCRIPTION field was built from unescaped user data
  (type, confirmation_number, notes, airline, flight/train numbers,
  airports) and written raw into ICS output; now passed through esc()
- Content-Disposition filename used ICS escaping instead of HTTP header
  sanitization; replaced with a character allowlist to prevent " and
  \r\n injection into the response header
2026-04-01 07:58:18 +02:00
jubnl e10f6bf9af fix: remove JWT_SECRET env var — server manages it exclusively
Setting JWT_SECRET via environment variable was broken by design:
the admin panel rotation updates the in-memory binding and persists
the new value to data/.jwt_secret, but an env var would silently
override it on the next restart, reverting the rotation.

The server now always loads JWT_SECRET from data/.jwt_secret
(auto-generating it on first start), making the file the single
source of truth. Rotation is handled exclusively through the admin
panel.

- config.ts: drop process.env.JWT_SECRET fallback and
  JWT_SECRET_IS_GENERATED export; always read from / write to
  data/.jwt_secret
- index.ts: remove the now-obsolete JWT_SECRET startup warning
- .env.example, docker-compose.yml, README: remove JWT_SECRET entries
- Helm chart: remove JWT_SECRET from secretEnv, secret.yaml, and
  deployment.yaml; rename generateJwtSecret → generateEncryptionKey
  and update NOTES.txt and README accordingly
2026-04-01 07:58:05 +02:00
jubnl 6f5550dc50 fix: decouple at-rest encryption from JWT_SECRET, add JWT rotation
Introduces a dedicated ENCRYPTION_KEY for encrypting stored secrets
(API keys, MFA TOTP, SMTP password, OIDC client secret) so that
rotating the JWT signing secret no longer invalidates encrypted data,
and a compromised JWT_SECRET no longer exposes stored credentials.

- server/src/config.ts: add ENCRYPTION_KEY (auto-generated to
  data/.encryption_key if not set, same pattern as JWT_SECRET);
  switch JWT_SECRET to `export let` so updateJwtSecret() keeps the
  CJS module binding live for all importers without restart
- apiKeyCrypto.ts, mfaCrypto.ts: derive encryption keys from
  ENCRYPTION_KEY instead of JWT_SECRET
- admin POST /rotate-jwt-secret: generates a new 32-byte hex secret,
  persists it to data/.jwt_secret, updates the live in-process binding
  via updateJwtSecret(), and writes an audit log entry
- Admin panel (Settings → Danger Zone): "Rotate JWT Secret" button
  with a confirmation modal warning that all sessions will be
  invalidated; on success the acting admin is logged out immediately
- docker-compose.yml, .env.example, README, Helm chart (values.yaml,
  secret.yaml, deployment.yaml, NOTES.txt, README): document
  ENCRYPTION_KEY and its upgrade migration path
2026-04-01 07:57:55 +02:00
jubnl dfdd473eca fix: validate uploaded backup DB before restore
Before swapping in a restored database, run PRAGMA integrity_check and
verify the five core TREK tables (users, trips, trip_members, places,
days) are present. This blocks restoring corrupt, empty, or unrelated
SQLite files that would otherwise crash the app immediately after swap,
and prevents a malicious admin from hot-swapping a crafted database with
forged users or permissions.
2026-04-01 07:57:42 +02:00
jubnl b515880adb fix: encrypt Immich API key at rest using AES-256-GCM
Per-user Immich API keys were stored as plaintext in the users table,
giving any attacker with DB read access full control over each user's
Immich photo server. Keys are now encrypted on write with
maybe_encrypt_api_key() and decrypted at the point of use via a shared
getImmichCredentials() helper. A new migration (index 66) back-fills
encryption for any existing plaintext values on startup.
2026-04-01 07:57:29 +02:00
jubnl 78695b4e03 fix: replace JWT tokens in URL query params with short-lived ephemeral tokens
Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file
download links, and Immich asset proxy URLs, leaking into server logs,
browser history, and Referer headers.

- Add ephemeralTokens service: in-memory single-use tokens with per-purpose
  TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup
- Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints
- WebSocket auth now consumes an ephemeral token instead of verifying the JWT
  directly from the URL; client fetches a fresh token before each connect
- File download ?token= query param now accepts ephemeral tokens; Bearer
  header path continues to accept JWTs for programmatic access
- Immich asset proxy replaces authFromQuery JWT injection with ephemeral token
  consumption
- Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async
  onClick handlers replace the synchronous authUrl() pattern throughout
  FileManager, PlaceInspector, and MemoriesPanel
- Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow
  overriding the auto-constructed discovery endpoint (required for Authentik
  and similar providers); exposed in the admin UI and .env.example
2026-04-01 07:57:14 +02:00
jubnl 0ee53e7b38 fix: prevent OIDC redirect URI construction from untrusted X-Forwarded-Host
The OIDC login route silently fell back to building the redirect URI from
X-Forwarded-Host/X-Forwarded-Proto when APP_URL was not configured. An
attacker could set X-Forwarded-Host: attacker.example.com to redirect the
authorization code to their own server after the user authenticates.

Remove the header-derived fallback entirely. If APP_URL is not set (via env
or the app_url DB setting), the OIDC login endpoint now returns a 500 error
rather than trusting attacker-controlled request headers. Document APP_URL
in .env.example as required for OIDC use.
2026-04-01 07:56:55 +02:00
jubnl 1b28bd96d4 fix: encrypt SMTP password at rest using AES-256-GCM
The smtp_pass setting was stored as plaintext in app_settings, exposing
SMTP credentials to anyone with database read access. Apply the same
encrypt_api_key/decrypt_api_key pattern already used for OIDC client
secrets and API keys. A new migration transparently re-encrypts any
existing plaintext value on startup; decrypt_api_key handles legacy
plaintext gracefully so in-flight reads remain safe during upgrade.
2026-04-01 07:56:43 +02:00
jubnl bba50f038b fix: encrypt OIDC client secret at rest using AES-256-GCM
The oidc_client_secret was written to app_settings as plaintext,
unlike Maps and OpenWeather API keys which are protected with
apiKeyCrypto. An attacker with read access to the SQLite file
(e.g. via a backup download) could obtain the secret and
impersonate the application with the identity provider.

- Encrypt on write in PUT /api/admin/oidc via maybe_encrypt_api_key
- Decrypt on read in GET /api/admin/oidc and in getOidcConfig()
  (oidc.ts) before passing the secret to the OIDC client library
- Add a startup migration that encrypts any existing plaintext value
  already present in the database
2026-04-01 07:56:29 +02:00
jubnl 701a8ab03a fix: route db helper functions through the null-safe proxy
getPlaceWithTags, canAccessTrip, and isOwner were calling _db! directly,
bypassing the Proxy that guards against null-dereference during a backup
restore. When the restore handler briefly sets _db = null, any concurrent
request hitting these helpers would crash with an unhandled TypeError
instead of receiving a clean 503-style error.

Replace all four _db! accesses with the exported db proxy so the guard
("Database connection is not available") fires consistently.
2026-04-01 07:56:01 +02:00
jubnl ccb5f9df1f fix: wrap each migration in a transaction and surface swallowed errors
Previously, the migration runner called each migration function directly with no transaction wrapping and updated schema_version only after all pending migrations had run. A mid-migration failure (e.g. disk full after ALTER TABLE but before CREATE INDEX) would leave the schema in a partially-applied state with no rollback path. On the next restart the broken migration would be skipped — because schema_version had not advanced — but only if the failure was noticed at all.

~43 catch {} blocks silently discarded every error, including non-idempotency errors such as disk-full or corruption, making it impossible to know a migration had failed.

Changes:
- Each migration now runs inside db.transaction(); better-sqlite3 rolls back automatically on throw.
- schema_version is updated after every individual migration succeeds, so a failure does not cause already-applied migrations to re-run.
- A migration that throws after rollback logs FATAL and calls process.exit(1), refusing to start with a broken schema.
- All catch {} blocks on ALTER TABLE ADD COLUMN re-throw any error that is not "duplicate column name", so only the expected idempotency case is swallowed.
- Genuinely optional steps (INSERT OR IGNORE, UPDATE data-copy, DROP TABLE IF EXISTS) now log a warning instead of discarding the error entirely.
2026-04-01 07:55:35 +02:00
jubnl c9341eda3f fix: remove RCE vector from admin update endpoint.
The POST /api/admin/update endpoint ran git pull, npm install, and npm run build via execSync, potentially giving any compromised admin account full code execution on the host in case repository is compromised. TREK ships as a Docker image so runtime self-updating is unnecessary.
- Remove the /update route and child_process import from admin.ts
- Remove the installUpdate API client method
- Replace the live-update modal with an info-only panel showing docker pull instructions and a link to the GitHub release
- Drop the updating/updateResult state and handleInstallUpdate handler
2026-04-01 07:55:34 +02:00
Maurice fb2e8d8209 fix: keep marker tooltip visible on touch devices when selected
On mobile/touch devices, Leaflet tooltips disappear immediately on tap
since there is no hover state. This makes the info bubble permanent for
the selected marker on touch devices so it stays readable.

Fixes #249
2026-04-01 00:11:12 +02:00
Maurice 27fb9246e6 Merge pull request #238 from slashwarm/feat/permissions-admin-panel
feat: configurable permissions system in admin
2026-04-01 00:05:14 +02:00
Gérnyi Márk 9a2c7c5db6 fix: address PR review feedback
- Suppress note context menu when canEditDays is false instead of
  showing empty menu
- Untie poll voting from collab_edit — voting is participation, not
  editing; any trip member can vote
- Restore NoteFormModal props (note, tripId) to required; remove
  leftover canUploadFiles prop in favor of direct zustand hook
2026-03-31 23:56:19 +02:00
Gérnyi Márk d1ad5da919 fix: tighten trip_edit and member_manage defaults to trip_owner
Previously defaulted to trip_member which is more permissive than
upstream behavior. Admins can still open it up via the panel.
2026-03-31 23:52:29 +02:00
Gérnyi Márk 1fbc19ad4f fix: add missing permission checks to file routes and map context menu
- Add checkPermission to 6 unprotected file endpoints (star, restore,
  permanent delete, empty trash, link, unlink)
- Gate map right-click place creation with place_edit permission
- Use file_upload permission for collab note file uploads
2026-03-31 23:45:11 +02:00
Gérnyi Márk 23edfe3dfc fix: harden permissions system after code review
- Gate permissions in /app-config behind optionalAuth so unauthenticated
  requests don't receive admin configuration
- Fix trip_delete isMember parameter (was hardcoded false)
- Return skipped keys from savePermissions for admin visibility
- Add disabled prop to CustomSelect, use in BudgetPanel currency picker
- Fix CollabChat reaction handler returning false instead of void
- Pass canUploadFiles as prop to NoteFormModal instead of internal store read
- Make edit-only NoteFormModal props optional (onDeleteFile, note, tripId)
- Add missing trailing newlines to .gitignore and it.ts
2026-03-31 23:36:17 +02:00
Gérnyi Márk 1ff8546484 fix: i18n chat reply/delete titles, gate collab category settings 2026-03-31 23:36:17 +02:00
Gérnyi Márk 6d18d5ed2d fix: gate collab notes category settings button with collab_edit 2026-03-31 23:36:16 +02:00
Gérnyi Márk 6d5067247c refactor: remove dead isAdmin prop from dashboard cards
Permission gating via useCanDo() makes the isAdmin prop redundant —
admin bypass is handled inside the permission system itself.
2026-03-31 23:36:16 +02:00
Gérnyi Márk 5e05bcd0db Revert "fix: change trip_edit to trip_owner"
This reverts commit 24f95be247ee0bdf49ab72fa69d4261c61194d63.
2026-03-31 23:36:16 +02:00
Gérnyi Márk 5f71b85c06 feat: add client-side permission gating to all write-action UIs
Gate all mutating UI elements with useCanDo() permission checks:
- BudgetPanel (budget_edit), PackingListPanel (packing_edit)
- DayPlanSidebar, DayDetailPanel (day_edit)
- ReservationsPanel, ReservationModal (reservation_edit)
- CollabNotes, CollabPolls, CollabChat (collab_edit)
- FileManager (file_edit, file_delete, file_upload)
- PlaceFormModal, PlaceInspector, PlacesSidebar (place_edit, file_upload)
- TripFormModal (trip_edit, trip_cover_upload)
- DashboardPage (trip_edit, trip_cover_upload, trip_delete, trip_archive)
- TripMembersModal (member_manage, share_manage)

Also: fix redundant getTripOwnerId queries in trips.ts, remove dead
getTripOwnerId function, fix TripMembersModal grid when share hidden,
fix canRemove logic, guard TripListItem empty actions div.
2026-03-31 23:36:16 +02:00
Gérnyi Márk d74133745a chore: update package-lock.json and .gitignore 2026-03-31 23:36:16 +02:00
Gérnyi Márk eee2bbe47a fix: change trip_edit to trip_owner 2026-03-31 23:36:16 +02:00
Gérnyi Márk c1bce755ca refactor: dedupe database requests 2026-03-31 23:36:15 +02:00
Gérnyi Márk 015be3d53a fix: incorrect hook order 2026-03-31 23:36:15 +02:00
Gérnyi Márk 7d3b37a2a3 feat: add configurable permissions system with admin panel
Adds a full permissions management feature allowing admins to control
who can perform actions across the app (trip CRUD, files, places,
budget, packing, reservations, collab, members, share links).

- New server/src/services/permissions.ts: 16 configurable actions,
  in-memory cache, checkPermission() helper, backwards-compatible
  defaults matching upstream behaviour
- GET/PUT /admin/permissions endpoints; permissions loaded into
  app-config response so clients have them on startup
- checkPermission() applied to all mutating route handlers across
  10 server route files; getTripOwnerId() helper eliminates repeated
  inline DB queries; trips.ts and files.ts now reuse canAccessTrip()
  result to avoid redundant DB round-trips
- New client/src/store/permissionsStore.ts: Zustand store +
  useCanDo() hook; TripOwnerContext type accepts both Trip and
  DashboardTrip shapes without casting at call sites
- New client/src/components/Admin/PermissionsPanel.tsx: categorised
  UI with per-action dropdowns, customised badge, save/reset
- AdminPage, DashboardPage, FileManager, PlacesSidebar,
  TripMembersModal gated via useCanDo(); no prop drilling
- 46 perm.* translation keys added to all 12 language files
2026-03-31 23:36:15 +02:00
Maurice ff1c1ed56a Merge branch 'dev' of https://github.com/mauriceboe/TREK into dev 2026-03-31 23:23:17 +02:00
Maurice d5674e9a11 fix: archive restore/delete buttons not visible in dark mode 2026-03-31 23:18:04 +02:00
Maurice 7eabe65bcf Merge pull request #240 from Summerfeeling/feat/more-currencies
feat: added all supported currencies from exchangerate-api (#229)
2026-03-31 23:12:32 +02:00
Maurice 3444e3f446 Merge branch 'perf-test' of https://github.com/jubnl/TREK into dev
# Conflicts:
#	client/src/components/Map/MapView.tsx
2026-03-31 23:10:02 +02:00
Maurice 9e3ac1e490 fix: increase max trip duration from 90 to 365 days 2026-03-31 22:58:27 +02:00
Maurice c38e70e244 fix: toggle switches not reflecting state in admin settings 2026-03-31 22:49:31 +02:00
Maurice ce7215341f fix: 12h time format input and display in bookings
- Allow typing AM/PM in time input when 12h format is active
- Format end time correctly in reservations panel (handle time-only strings)
2026-03-31 22:40:59 +02:00
Maurice 4733955531 fix: render Lucide category icons on map markers instead of text/emoji 2026-03-31 22:35:43 +02:00
Maurice 36267de117 fix: bag modal cut off on small screens 2026-03-31 22:27:26 +02:00
Maurice cd13399da5 fix: show selected map template in settings dropdown 2026-03-31 22:18:42 +02:00
Maurice 36cd2feca5 fix: use Nominatim reverse geocoding for accurate country detection in atlas
Bounding boxes overlap for neighboring countries (e.g. Munich matched
Austria instead of Germany). Now uses Nominatim reverse geocoding with
in-memory cache as primary fallback, bounding boxes only as last resort.
2026-03-31 21:58:20 +02:00
Maurice fbe3b5b17e Merge pull request #225 from andreibrebene/improvements/various-improvements
Improvements/various improvements
2026-03-31 21:40:26 +02:00
Maurice 10107ecf31 fix: require auth for file downloads, localize atlas search, use flag images
- Block direct access to /uploads/files (401), serve via authenticated
  /api/trips/:tripId/files/:id/download with JWT verification
- Client passes auth token as query parameter for direct links
- Atlas country search now uses Intl.DisplayNames (user language) instead
  of English GeoJSON names
- Atlas search results use flagcdn.com flag images instead of emoji
2026-03-31 21:38:16 +02:00
Andrei Brebene 94d698e39f docs: simplify README docker-compose example to essential env vars only
Made-with: Cursor
2026-03-31 22:24:08 +03:00
Andrei Brebene 6c88a01123 docs: document all env vars and remove SMTP/webhook from docker config
SMTP and webhook settings are configured via Admin UI only.

Made-with: Cursor
2026-03-31 22:24:07 +03:00
Andrei Brebene 75af89de30 docs: remove SMTP and webhook env vars (configured via Admin UI only)
Made-with: Cursor
2026-03-31 22:23:53 +03:00
Andrei Brebene ed8518aca4 docs: document all environment variables in docker-compose, .env.example, and README
Made-with: Cursor
2026-03-31 22:23:53 +03:00
Andrei Brebene 7522f396e7 feat: configurable trip reminders, admin full access, and enhanced audit logging
- Add configurable trip reminder days (1, 3, 9 or custom up to 30) settable by trip owner
- Grant administrators full access to edit, archive, delete, view and list all trips
- Show trip owner email in audit logs and docker logs when admin edits/deletes another user's trip
- Show target user email in audit logs when admin edits or deletes a user account
- Use email instead of username in all notifications (Discord/Slack/email) to avoid ambiguity
- Grey out notification event toggles when no SMTP/webhook is configured
- Grey out trip reminder selector when notifications are disabled
- Skip local admin account creation when OIDC_ONLY=true with OIDC configured
- Conditional scheduler logging: show disabled reason or active reminder count
- Log per-owner reminder creation/update in docker logs
- Demote 401/403 HTTP errors to DEBUG log level to reduce noise
- Hide edit/archive/delete buttons for non-owner invited users on trip cards
- Fix literal "0" rendering on trip cards from SQLite numeric is_owner field
- Add missing translation keys across all 14 language files

Made-with: Cursor
2026-03-31 22:23:38 +03:00
Andrei Brebene 9b2f083e4b feat: notifications, audit logging, and admin improvements
- Add centralized notification service with webhook (Discord/Slack) and
  email (SMTP) support, triggered for trip invites, booking changes,
  collab messages, and trip reminders
- Webhook sends one message per event (group channel); email sends
  individually per trip member, excluding the actor
- Discord invite notifications now include the invited user's name
- Add LOG_LEVEL env var (info/debug) controlling console and file output
- INFO logs show user email, action, and IP for audit events; errors
  for HTTP requests
- DEBUG logs show every request with full body/query (passwords redacted),
  audit details, notification params, and webhook payloads
- Add persistent trek.log file logging with 10MB rotation (5 files)
  in /app/data/logs/
- Color-coded log levels in Docker console output
- Timestamps without timezone name (user sets TZ via Docker)
- Add Test Webhook and Save buttons to admin notification settings
- Move notification event toggles to admin panel
- Add daily trip reminder scheduler (9 AM, timezone-aware)
- Wire up booking create/update/delete and collab message notifications
- Add i18n keys for notification UI across all 13 languages

Made-with: Cursor
2026-03-31 22:23:23 +03:00
jubnl 9a949d7391 Performance on trip planner (Maybe ?) 2026-03-31 21:13:29 +02:00
Summerfeeling | Timo 13904fb702 feat: added all supported currencies from exchangerate-api (#229) 2026-03-31 21:04:59 +02:00
Maurice f7160e6dec Merge pull request #179 from shanelord01/audit/remediation-clean
Automated Security & Quality Audit via Claude Code
2026-03-31 20:53:48 +02:00
Maurice 1983691950 Merge branch 'feat/add-searchbar-in-atlas' of https://github.com/Akashic101/NOMAD into dev
# Conflicts:
#	client/src/i18n/translations/cs.ts
#	client/src/i18n/translations/it.ts
2026-03-31 20:29:23 +02:00
Maurice 6866644d0c Merge pull request #189 from M-Enderle/feat/gpx-full-route-import
feat(add-gpx-tracks): adds better gpx track views
2026-03-31 20:17:22 +02:00
Maurice b120aabaa3 Merge pull request #191 from M-Enderle/feat/mcp-improvements
feat(mcp-improvements): add search_place, list_categories tools + fix opening hours in MCP
2026-03-31 20:16:04 +02:00
Maurice 1d442c1d7a Merge pull request #182 from BKSalman/mobile-fixes
mobile UI fixes
2026-03-31 20:14:30 +02:00
Maurice 9a0294360c Merge pull request #181 from BKSalman/accom-fix
fix: update dayAccommodations state after create/edit/delete
2026-03-31 20:10:42 +02:00
Maurice 9de0c5b051 Merge remote-tracking branch 'origin/dev' into asteriskyg/main
# Conflicts:
#	server/src/routes/files.ts
2026-03-31 20:08:42 +02:00
Maurice 9e9b86f1b4 Merge branch 'fix/encrypt-api-keys' of https://github.com/Akashic101/NOMAD into dev 2026-03-31 20:03:55 +02:00
David Moll 8ff5ec486f Merge branch 'main' into feat/add-searchbar-in-atlas 2026-03-31 12:31:14 +02:00
David Moll 5576339bcc feat(atlas): add searchbar 2026-03-31 12:27:13 +02:00
Moritz Enderle e668e80f1c feat: add search_place, list_categories tools + fix opening hours in MCP
- Add google_place_id and osm_id params to create_place tool so the app
  can fetch opening hours and ratings for MCP-created places
- Add list_categories tool for discovering category IDs
- Add search_place tool (Nominatim) to look up osm_id before creating
2026-03-31 10:38:29 +02:00
Moritz Enderle 3aaa6e916b feat: adds better gpx track views 2026-03-31 10:29:49 +02:00
Maurice ad329eddb9 Merge pull request #176 from jubnl/main
Prevent duplicate place assignment when dragging to an empty day
2026-03-31 10:00:37 +02:00
David Moll 990e804bd3 fix(server): encrypt api keys 2026-03-31 09:00:35 +02:00
Salman Abuhaimed 299e26bebe make day plan side bar icons more readable 2026-03-31 06:29:31 +03:00
Salman Abuhaimed 96b6d7d81f fix: note modal hidden behind mobile sidebar due to z-index 2026-03-31 06:29:31 +03:00
Salman Abuhaimed 27d5c3400c fix: update dayAccommodations state after create/edit/delete 2026-03-31 06:27:45 +03:00
Salman Abuhaimed bb9c0c9b68 fix: day details on mobile not showing 2026-03-31 06:27:11 +03:00
Claude 483190e7c1 fix: XSS in GitHubPanel markdown renderer and RouteCalculator profile bug
Escape HTML entities before dangerouslySetInnerHTML in release notes
renderer to prevent stored XSS via malicious GitHub release bodies.
Fix RouteCalculator ignoring the profile parameter (was hardcoded to
'driving').

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:09 +00:00
Claude c89ff8b551 fix: critical Immich SSRF and API key exposure vulnerabilities
- Add URL validation on Immich URL save to prevent SSRF attacks
  (blocks private IPs, metadata endpoints, non-HTTP protocols)
- Remove userId query parameter from asset proxy endpoints to prevent
  any authenticated user from accessing another user's Immich API key
  and photo library
- Add asset ID validation (alphanumeric only) to prevent path traversal
  in proxied Immich API URLs
- Update AUDIT_FINDINGS.md with Immich and admin route findings

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:06 +00:00
Claude 63232e56a3 fix: prevent OIDC token data leaking to logs, update audit findings
- Redact OIDC token exchange error logs to only include HTTP status
- Add additional findings from exhaustive server security scan to
  AUDIT_FINDINGS.md

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:04 +00:00
Claude 643504d89b fix: infrastructure hardening and documentation improvements
- Add *.sqlite* patterns to .gitignore
- Expand .dockerignore to exclude chart/, docs/, .github/, etc.
- Add HEALTHCHECK instruction to Dockerfile
- Fix Helm chart: preserve JWT secret across upgrades (lookup),
  add securityContext, conditional PVC creation, resource defaults
- Remove hardcoded demo credentials from MCP.md
- Complete .env.example with all configurable environment variables

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:01 +00:00
Claude 2288f9d2fc fix: harden PWA caching and client-side auth security
- Exclude sensitive API paths (auth, admin, backup, settings) from SW cache
- Restrict upload caching to public assets only (covers, avatars)
- Remove opaque response caching (status 0) for API and uploads
- Clear service worker caches on logout
- Only logout on 401 errors, not transient network failures
- Fix register() TypeScript interface to include invite_token parameter
- Remove unused RegisterPage and DemoBanner imports
- Disable source maps in production build
- Add SRI hash for Leaflet CSS CDN

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:58 +00:00
Claude 804c2586a9 fix: tighten CSP, fix API key exposure, improve error handling
- Remove 'unsafe-inline' from script-src CSP directive
- Restrict connectSrc and imgSrc to known external domains
- Move Google API key from URL query parameter to X-Goog-Api-Key header
- Sanitize error logging in production (no stack traces)
- Log file link errors instead of silently swallowing them

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:56 +00:00
Claude fedd559fd6 fix: pin JWT algorithm to HS256 and harden token security
- Add { algorithms: ['HS256'] } to all jwt.verify() calls to prevent
  algorithm confusion attacks (including the 'none' algorithm)
- Add { algorithm: 'HS256' } to all jwt.sign() calls for consistency
- Reduce OIDC token payload to only { id } (was leaking username, email, role)
- Validate OIDC redirect URI against APP_URL env var when configured
- Add startup warning when JWT_SECRET is auto-generated

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:53 +00:00
Claude 5f07bdaaf1 docs: add comprehensive security and code quality audit findings
AUDIT_FINDINGS.md documents all findings across security, code quality,
best practices, dependency hygiene, documentation, and testing categories.

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:50 +00:00
jubnl fb643a1ade fix: stop drop event bubbling causing duplicate place assignment 2026-03-31 01:32:20 +02:00
Maurice 069fd99341 Merge branch 'pr-169'
# Conflicts:
#	client/src/i18n/translations/ar.ts
#	client/src/i18n/translations/br.ts
#	client/src/i18n/translations/cs.ts
#	client/src/i18n/translations/de.ts
#	client/src/i18n/translations/en.ts
#	client/src/i18n/translations/es.ts
#	client/src/i18n/translations/fr.ts
#	client/src/i18n/translations/hu.ts
#	client/src/i18n/translations/it.ts
#	client/src/i18n/translations/nl.ts
#	client/src/i18n/translations/ru.ts
#	client/src/i18n/translations/zh.ts
#	client/src/pages/SettingsPage.tsx
2026-03-30 23:46:32 +02:00
Maurice 3dc760484a Merge pull request #166 from fgbona/feat/#155
feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
2026-03-30 23:42:45 +02:00
Fernando Bona 13580ea5fb Merge branch 'main' into feat/#155 2026-03-30 18:36:18 -03:00
Fernando Bona aa5dd1abc6 Merge branch 'main' into fix/mfa-backup-codes 2026-03-30 18:27:46 -03:00
fgbona de444bf770 fix(mfa-backup-codes): persist backup codes panel after enable and refresh
Keep MFA backup codes visible after enabling MFA by avoiding protected-route unmount during user reload (`loadUser({ silent: true })`) and restoring pending backup codes from sessionStorage until the user explicitly dismisses them.
2026-03-30 18:22:45 -03:00
Maurice 821f71ac28 fix: add MCP translation keys for cs, hu, it languages 2026-03-30 23:14:05 +02:00
Maurice faebc62917 Merge branch 'pr-125'
# Conflicts:
#	client/src/api/client.ts
#	client/src/i18n/translations/ar.ts
#	client/src/i18n/translations/es.ts
#	client/src/i18n/translations/fr.ts
#	client/src/i18n/translations/nl.ts
#	client/src/i18n/translations/ru.ts
#	client/src/i18n/translations/zh.ts
#	client/src/pages/AdminPage.tsx
#	client/src/pages/SettingsPage.tsx
#	server/package.json
#	server/src/db/migrations.ts
#	server/src/index.ts
#	server/src/routes/admin.ts
2026-03-30 23:10:34 +02:00
Fernando Bona 41e572445c Merge branch 'main' into feat/#155 2026-03-30 17:52:07 -03:00
fgbona 66f5ea50c5 feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
Add an admin-controlled `require_mfa` policy in App Settings and expose it via `/auth/app-config` so the client can enforce it globally. Users without MFA are redirected to Settings after login and blocked from protected API/WebSocket access until setup is completed, while preserving MFA setup endpoints and admin recovery paths. Also prevent enabling the policy unless the acting admin already has MFA enabled, and block MFA disable while the policy is active. Includes UI toggle in Admin > Settings, required-policy notice in Settings, client-side 403 `MFA_REQUIRED` handling, and i18n updates for all supported locales.
2026-03-30 17:42:40 -03:00
Maurice ce4b8088ec fix: force light mode on shared trip page 2026-03-30 22:32:58 +02:00
Maurice b1138eb9db fix: shared page language redirect + skip TLS for self-signed certs — closes #163 #164
- Language change on public shared page no longer triggers API call / login redirect
- New "Skip TLS certificate check" toggle in Admin > SMTP settings
- Also configurable via SMTP_SKIP_TLS_VERIFY=true env var
2026-03-30 22:26:09 +02:00
Maurice 8412f303dd fix: Dockerfile volume permissions — fix SQLITE_READONLY on upgrade 2026-03-30 21:38:28 +02:00
Maurice 7272e0bbfd chore: bump version to 2.7.1 2026-03-30 21:25:35 +02:00
Maurice c7eaf3aa79 feat: add Italian, Czech, Hungarian + sync all 12 languages
New languages: Italian (it), Czech (cs), Hungarian (hu)
Merged PRs #158, #130, #119 with conflict resolution.

All 12 language files synced to ~1238 keys each:
ar, br, cs, de, en, es, fr, hu, it, nl, ru, zh

Thanks @entropyst72 (Italian), @Numira-code (Czech),
@slashwarm (Hungarian) for the translations!
2026-03-30 21:22:53 +02:00
Maurice deef5e6b81 Merge branch 'pr-130' into dev 2026-03-30 21:02:32 +02:00
Maurice 6d72006b28 Merge branch 'pr-158' into dev 2026-03-30 21:02:18 +02:00
Maurice 26c1676cdd revert: remove auth from file uploads — breaks img/pdf rendering in browser 2026-03-30 20:56:56 +02:00
Maurice 4ddfa92c14 security: require auth for file and photo uploads
/uploads/files/ and /uploads/photos/ now require a valid Bearer token.
Covers and avatars remain public (needed for shared pages and profiles).
Prevents unauthenticated access to uploaded documents and trip photos.
2026-03-30 20:51:38 +02:00
Maurice 19c9e17884 Merge branch 'pr-120' into dev 2026-03-30 20:09:16 +02:00
Maurice 14ef2d4a4a Merge branch 'pr-117' into dev 2026-03-30 20:07:12 +02:00
Maurice de859318fa feat: admin audit log — merged PR #118
Audit logging for admin actions, backups, auth events.
New AuditLogPanel in Admin tab with pagination.
Dockerfile security: run as non-root user.
i18n keys for all 9 languages.

Thanks @fgbona for the implementation!
2026-03-30 20:05:32 +02:00
Maurice bcbb516448 refactor: replace hardcoded Vacay month/weekday arrays with Intl + i18n — based on PR #122
Remove 12 hardcoded arrays for weekdays/months across 6 languages.
Use Intl.DateTimeFormat for month names and i18n keys for weekdays.
Works for all locales automatically.

Thanks @slashwarm for the original PR!
2026-03-30 19:59:47 +02:00
Maurice 71870e4567 Merge branch 'pr-149' into dev 2026-03-30 19:53:08 +02:00
entropyst72 9819473157 added italian language 2026-03-30 19:43:46 +02:00
Maurice eb7984f40d fix: CustomSelect for backup schedule dropdowns, increase PWA cache limit
- Replace native <select> with CustomSelect for hour and day-of-month
  pickers in backup schedule settings (consistent UI)
- Increase PWA workbox cache size limit to 5MB
2026-03-30 19:39:54 +02:00
Maurice 9caa0acc24 fix: language dropdown not clipped by header overflow 2026-03-30 18:25:40 +02:00
Maurice 8ddfa8fde0 i18n: translate all shared trip page strings to 9 languages 2026-03-30 18:24:22 +02:00
Maurice 41d4b2a8be i18n: sync all 9 language files to match en.ts (1210+ keys each) 2026-03-30 18:19:22 +02:00
fgbona 10ebf46a98 harden runtime config and automate first-run permissions
Run the container as a non-root user in production to fail fast on insecure deployments. Add DEBUG env-based request/response logging for container diagnostics, and introduce a one-shot init-permissions service in docker-compose so fresh installs automatically fix data/uploads ownership for SQLite write access.
2026-03-30 13:19:01 -03:00
Maurice 70809d6c27 fix: TimezoneWidget respects 12h/24h setting, addon notification toggles, cover image path — closes #147 2026-03-30 18:08:22 +02:00
Maurice a314ba2b80 feat: public read-only share links with permissions — closes #79
Share links:
- Generate a public link in the trip share modal
- Choose what to share: Map & Plan, Bookings, Packing, Budget, Chat
- Permissions enforced server-side
- Delete link to revoke access instantly

Shared trip page (/shared/:token):
- Read-only view with TREK logo, cover image, trip details
- Tabbed navigation with Lucide icons (responsive on mobile)
- Interactive map with auto-fit bounds per day
- Day plan, Bookings, Packing, Budget, Chat views
- Language picker, TREK branding footer

Technical:
- share_tokens DB table with per-field permissions
- Public GET /shared/:token endpoint (no auth)
- Two-column share modal (max-w-5xl)
2026-03-30 18:02:53 +02:00
Xre0uS d8f03f6bea fix: prevent OIDC redirect loop in oidc-only mode 2026-03-30 23:57:23 +08:00
Maurice 533d6f84d8 fix: use user locale instead of hardcoded de-DE for number/date formatting — closes #144
- CurrencyWidget: format numbers with user's locale
- ReservationModal: date formatting uses locale
- TripPDF: locale fallback to browser default instead of de-DE
- holidays.ts: formatDate accepts optional locale parameter
2026-03-30 17:28:14 +02:00
Maurice 095cb1b9d1 fix: transport bookings in PDF export with proper Lucide icons 2026-03-30 17:22:06 +02:00
Maurice 0a0205fcf9 fix: ICS export — add DTSTAMP, fix time-only DTEND formatting 2026-03-30 17:14:06 +02:00
Maurice 9aed5ff2ed fix: ICS export auth token key (auth_token not token) 2026-03-30 17:09:44 +02:00
Maurice d189d6d776 feat: email notifications, webhook support, ICS export — closes #110
Email Notifications:
- SMTP configuration in Admin > Settings (host, port, user, pass, from)
- App URL setting for email CTA links
- Webhook URL support (Discord, Slack, custom)
- Test email button with SMTP validation
- Beautiful HTML email template with TREK logo, slogan, red heart footer
- All notification texts translated in 8 languages (en/de/fr/es/nl/ru/zh/ar)
- Emails sent in each user's language preference

Notification Events:
- Trip invitation (member added)
- Booking created (new reservation)
- Vacay fusion invite
- Photos shared (Immich)
- Collab chat message
- Packing list category assignment

User Notification Preferences:
- Per-user toggle for each event type in Settings
- Addon-aware: Vacay/Collab/Photos toggles hidden when addon disabled
- Webhook opt-in per user

ICS Calendar Export:
- Download button next to PDF in day plan header
- Exports trip dates + all reservations with details
- Compatible with Google Calendar, Apple Calendar, Outlook

Technical:
- Nodemailer for SMTP
- notification_preferences DB table with per-event columns
- GET/PUT /auth/app-settings for admin config persistence
- POST /notifications/test-smtp for validation
- Dynamic imports for non-blocking notification sends
2026-03-30 17:07:33 +02:00
Maurice 262905e357 feat: import places from Google Maps URLs — closes #141
Paste a Google Maps URL into the place search bar to automatically
import name, coordinates, and address. No API key required.

Supported URL formats:
- Short URLs: maps.app.goo.gl/..., goo.gl/maps/...
- Full URLs: google.com/maps/place/.../@lat,lng
- Data params: !3dlat!4dlng embedded coordinates

Server resolves short URL redirects and extracts coordinates.
Reverse geocoding via Nominatim provides name and address.
2026-03-30 15:18:22 +02:00
Maurice 4a4643f33f feat: OIDC claim-based admin role assignment — closes #93
New environment variables:
- OIDC_ADMIN_CLAIM (default: "groups") — which claim to check
- OIDC_ADMIN_VALUE (e.g. "app-trek-admins") — value that grants admin

Admin role is resolved on every OIDC login:
- New users get admin if their claim matches
- Existing users have their role updated dynamically
- Removing a user from the group revokes admin on next login
- First user is always admin regardless of claims
- No config = previous behavior (first user admin, rest user)

Supports array claims (groups: ["a", "b"]) and string claims.
2026-03-30 15:12:27 +02:00
Maurice a6a7edf0b2 feat: bucket list POIs with auto-search + optional dates — closes #105
- Bucket list now supports POIs (not just countries): add any place
  with auto-search via Google Places / Nominatim
- Optional target date (month/year) via CustomSelect dropdowns
- New target_date field on bucket_list table (DB migration)
- Server PUT route supports updating all fields
- Country bucket modal: date dropdowns default to empty
- CustomSelect: auto-opens upward when near bottom of viewport
- Search results open upward in the bucket add form
- i18n keys for DE and EN
2026-03-30 14:57:31 +02:00
Maurice 949d0967d2 feat: timezone support + granular backup schedule — closes #131
Based on PR #135 by @andreibrebene with adjustments:
- TZ environment variable for Docker timezone support
- Granular auto-backup schedule (hour, day of week, day of month)
- UTC timestamp fix for admin panel
- Server timezone exposed in app-config API
- Replaced native selects with CustomSelect for consistent UI
- Backup schedule UI with 12h/24h time format support

Thanks @andreibrebene for the implementation!
2026-03-30 14:02:27 +02:00
Maurice cd634093af feat: multi-select category filter, performance fixes, check-in/out order
- Category filter is now a multi-select dropdown with checkboxes
- PlaceAvatar: replace 200ms polling intervals with event-based
  notification + React.memo for major performance improvement
- Map photo fetches: concurrency limited to 3 + lazy loading on images
- PlacesSidebar: content-visibility + useMemo for smooth scrolling
- Accommodation labels: check-out now appears before check-in on same day
- Timed places auto-sort chronologically when time is added
2026-03-30 13:52:35 +02:00
Maurice 7201380504 fix: paginate Immich photo search — no longer limited to 200 — closes #137
The Immich metadata search was hardcoded to size: 200. Now paginates
through all results (1000 per page, up to 20k photos max).
2026-03-30 13:36:04 +02:00
ASTERISK Kwon ba87a7f876 fix: correct linksMap type annotation 2026-03-30 20:32:49 +09:00
ASTERISK Kwon 9f1b0554d6 fix: decode multer filename encoding for non-ASCII filenames 2026-03-30 20:31:04 +09:00
Maurice 1166a09835 feat: live GPS location on map + auto-sort timed places — closes #136
Live location:
- Crosshair button on the map toggles GPS tracking
- Blue dot shows live position with accuracy circle (<500m)
- Uses watchPosition for continuous updates
- Button turns blue when active, click again to stop

Auto-sort:
- Places with a time now auto-sort chronologically among other
  timed items (transports, other timed places)
- Adding a time to a place immediately moves it to the correct
  position in the timeline
- Untimed places keep their manual order_index
2026-03-30 13:30:41 +02:00
Andrei Brebene 6f2d7c8f5e Merge branch 'dev' into feat/auto-backup-schedule-and-timezone 2026-03-30 13:23:19 +03:00
Maurice e6c4c22a1d feat: bulk import for packing lists + complete i18n sync — closes #133
Packing list bulk import:
- Import button in packing list header opens a modal
- Paste items or load CSV/TXT file
- Format: Category, Name, Weight (g), Bag, checked/unchecked
- Bags are auto-created if they don't exist
- Server endpoint POST /packing/import with transaction

i18n sync:
- Added all missing translation keys to fr, es, nl, ru, zh, ar
- All 8 language files now have matching key sets
- Includes memories, vacay weekdays, packing import, settlement,
  GPX import, blur booking codes, transport timeline keys
2026-03-30 12:16:00 +02:00
Maurice 9a044ada28 feat: blur booking codes setting + two-column settings page — closes #114
- New display setting "Blur Booking Codes" (off by default)
- When enabled, confirmation codes are blurred across all views
  (ReservationsPanel, DayDetailPanel, Transport detail modal)
- Hover or click reveals the code (click toggles on mobile)
- Settings page uses masonry two-column layout on desktop, single
  column on mobile (<900px)
- Fix hardcoded admin page title to use i18n key
2026-03-30 11:47:05 +02:00
Maurice da5e77f78d feat: GPX file import for places — closes #98
Upload a GPX file to automatically create places from waypoints.
Supports <wpt>, <rtept>, and <trkpt> elements with CDATA handling.
Handles lat/lon in any attribute order. Track-only files import
start and end points with the track name.

- New server endpoint POST /places/import/gpx
- Import GPX button in PlacesSidebar below Add Place
- i18n keys for DE and EN
2026-03-30 11:35:28 +02:00
Andrei Brebene cc8be328f9 feat: add granular auto-backup scheduling and timezone support
Add UI controls for configuring auto-backup schedule with hour, day of
week, and day of month pickers. The hour picker respects the user's
12h/24h time format preference from settings.

Add TZ environment variable support via docker-compose so the container
runs in the configured timezone. The timezone is passed to node-cron for
accurate scheduling and exposed via the API so the UI displays it.

Fix SQLite UTC timestamp handling by appending Z suffix to all timestamps
sent to the client, ensuring proper timezone conversion in the browser.

Made-with: Cursor
2026-03-30 12:27:52 +03:00
Maurice f1c4155d81 feat: add Brazilian Portuguese (pt-BR) language support — thanks @fgbona 2026-03-30 12:27:21 +03:00
Fabian Sievert d4899a8dee feat: add Helm chart for Kubernetes deployment — thanks @another-novelty
* feat: Add basic helm chart

* Delete chart/my-values.yaml
2026-03-30 12:27:21 +03:00
AxelFl a973a1b4f8 docs: fix docker image name in SECURITY.md — thanks @AxelFl 2026-03-30 12:27:21 +03:00
Maurice 73b0534053 feat: add missing French translation keys for memories and weekend days 2026-03-30 12:27:21 +03:00
quentinClaudel 931c5bd990 feat: improve French translations — thanks @quentinClaudel 2026-03-30 12:27:21 +03:00
Maurice ee54308819 feat: expand budget currencies from 14 to 46 — closes #96
Add BDT, INR, BRL, MXN, KRW, CNY, SGD, PHP, VND, ZAR, AED, SAR, ILS,
EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, LKR, CLP, COP, PEN, ARS,
NZD, IDR, MYR, HKD, TWD with correct currency symbols.
2026-03-30 11:16:23 +02:00
Gérnyi Márk 66b00c24e2 add leftWidth/rightWidth centering to PlaceInspector 2026-03-30 11:15:57 +02:00
Maurice f6d08582ec feat: expense settlement — track who paid, show who owes whom — closes #41
- Click member avatars on budget items to mark who paid (green = paid)
- Multiple green chips = those people split the payment equally
- Settlement dropdown in the total budget card shows optimized payment
  flows (who owes whom how much) and net balances per person
- Info tooltip explains how the feature works
- New server endpoint GET /budget/settlement calculates net balances
  and minimized payment flows using a greedy algorithm
- Merged category legend: amount + percentage in one row
- i18n keys added for DE and EN
2026-03-30 11:12:22 +02:00
Maurice 8d9a511edf fix: auto-invalidate cache on version update — closes #121
- Add version check on app startup: compare server version with stored
  client version, clear all SW caches and reload on mismatch
- Set Cache-Control: no-cache on index.html so browsers always fetch
  the latest version instead of serving stale cached HTML
2026-03-30 10:26:23 +02:00
Maurice 3059d53d11 fix: use 50m resolution GeoJSON for Atlas — show smaller countries — closes #115
Switch from ne_110m to ne_50m Natural Earth dataset so small countries
like Seychelles, Maldives, Monaco etc. are visible in the Atlas view
and visited countries status.
2026-03-30 10:19:17 +02:00
Maurice 3074724f2f feat: show transport bookings in day plan timeline — closes #37
Transport reservations (flights, trains, buses, cars, cruises) now appear
directly in the day plan timeline based on their reservation date/time.

- Transport cards display inline with places and notes, sorted by time
- Click to open detail modal with all booking data and linked files
- Persistent positioning via new day_plan_position field on reservations
- Free drag & drop: places can be moved between/around transport entries
- Arrow reorder works on the full visual list including transports
- Timed places show confirmation popup when reorder breaks chronology
- Custom delete confirmation popup for reservations
- DB migration adds day_plan_position column to reservations table
- New batch endpoint PUT /reservations/positions for position updates
- i18n keys added for DE and EN
2026-03-30 10:15:27 +02:00
Numira 21ed7ea4a2 Change GeoJSON fetch URL to 110m resolution
Updated GeoJSON data source to use 110m resolution.
2026-03-30 10:03:11 +02:00
Numira 267271d97a Change GeoJSON fetch URL to 50m resolution
Updated GeoJSON data source for country boundaries.
2026-03-30 09:40:11 +02:00
Numira 874c1292c7 Add Czech language support to translation context 2026-03-30 09:32:34 +02:00
Numira a9948499e4 Add files via upload
Added support for Czech language (complete translation of all strings)
2026-03-30 09:24:52 +02:00
jubnl 3dd15499e6 Add documentation 2026-03-30 05:37:30 +02:00
jubnl 393e99201a Add documentation 2026-03-30 05:35:14 +02:00
jubnl 153b7f64b7 some fixes 2026-03-30 06:59:24 +02:00
jubnl 7b2d45665c Merge remote-tracking branch 'origin/main'
# Conflicts:
#	server/src/db/migrations.ts
2026-03-30 03:56:05 +02:00
jubnl 37873dd938 feat: mcp server 2026-03-30 03:53:45 +02:00
Gérnyi Márk 90301e62ce fix type signature, sync keys with upstream, fix atlas.tripIn translation 2026-03-30 01:07:11 +02:00
Gérnyi Márk 377422a9d5 add race condition detection for invite token usage 2026-03-30 00:59:02 +02:00
Gérnyi Márk d90a059dfa pass leftWidth/rightWidth from TripPlannerPage to DayDetailPanel 2026-03-30 00:52:41 +02:00
Gérnyi Márk 1e20f024d5 use leftWidth/rightWidth to center panel between sidebars 2026-03-30 00:46:06 +02:00
Gérnyi Márk 9a81baa809 feat: add leftWidth/rightWidth layout props to DayDetailPanel 2026-03-30 00:44:28 +02:00
Gérnyi Márk 11b85a2d70 feat: add Hungarian language support 2026-03-30 00:43:42 +02:00
fgbona d04629605e feat(audit): admin audit log
Audit log
- Add audit_log table (migration + schema) with index on created_at.
- Add auditLog service (writeAudit, getClientIp) and record events for backups
  (create, restore, upload-restore, delete, auto-settings), admin actions
  (users, OIDC, invites, system update, demo baseline, bag tracking, packing
  template delete, addons), and auth (app settings, MFA enable/disable).
- Add GET /api/admin/audit-log with pagination; fix invite insert row id lookup.
- Add AuditLogPanel and Admin tab; adminApi.auditLog.
- Add admin.tabs.audit and admin.audit.* strings in all locale files.
Note: Rebase feature branches so new DB migrations stay after existing ones
  (e.g. file_links) when merging upstream.
2026-03-29 19:39:05 -03:00
Gérnyi Márk 187989cc1d feat: pass invite token through OIDC flow to allow invited registration
When registration is disabled, users with a valid invite link can now
register via OIDC/SSO. The invite token is passed from the login page
through the OIDC state, validated on callback, and used to bypass the
allow_registration check. Invite usage count is incremented after
successful registration.
2026-03-30 00:35:53 +02:00
Maurice 6444b2b4ce feat: add Brazilian Portuguese (pt-BR) language support — thanks @fgbona 2026-03-29 23:55:46 +02:00
Fabian Sievert 42ebc7c298 feat: add Helm chart for Kubernetes deployment — thanks @another-novelty
* feat: Add basic helm chart

* Delete chart/my-values.yaml
2026-03-29 23:44:20 +02:00
AxelFl 8bca921b30 docs: fix docker image name in SECURITY.md — thanks @AxelFl 2026-03-29 23:42:11 +02:00
Maurice 12f8b6eb55 feat: add missing French translation keys for memories and weekend days 2026-03-29 23:38:51 +02:00
quentinClaudel 202cfb6a63 feat: improve French translations — thanks @quentinClaudel 2026-03-29 23:36:56 +02:00
Maurice b6f9664ec2 feat: multi-link files to multiple bookings and places — closes #23
Files can now be linked to multiple bookings and places simultaneously
via a new file_links junction table. Booking modal includes a file picker
to link existing uploads. Unlinking removes the association without
deleting the file.
2026-03-29 23:32:04 +02:00
Maurice 9f8075171d feat: Immich photo integration — Photos addon with sharing, filters, lightbox
- Immich connection per user (Settings → Immich URL + API Key)
- Photos addon (admin-toggleable, trip tab)
- Manual photo selection from Immich library (date filter + all photos)
- Photo sharing with consent popup, per-photo privacy toggle
- Lightbox with liquid glass EXIF info panel (camera, lens, location, settings)
- Location filter + date sort in gallery
- WebSocket live sync when photos are added/removed/shared
- Proxy endpoints for thumbnails and originals with token auth
2026-03-29 22:41:39 +02:00
Maurice 02b907e764 fix: manually marked Atlas countries not saved when no trips exist — closes #95 2026-03-29 22:37:21 +02:00
Maurice e05e021f41 fix: prevent duplicate packing category names from merging — auto-append number — closes #100 2026-03-29 22:37:21 +02:00
Maurice 615c6bae58 fix: Bangladesh pins incorrectly shown as India in Atlas — add BD bounding box — closes #106 2026-03-29 22:37:21 +02:00
Maurice 62fbc26811 fix: GitHub panel blank screen — add missing releases endpoint, fix NOMAD→TREK URL — closes #107 2026-03-29 22:37:21 +02:00
Maurice 2171203a4c feat: configurable weekend days in Vacay — closes #97
Users can now select which days are weekends (default: Sat+Sun).
Useful for countries like Bangladesh (Fri+Sat) or others with
different work weeks. Settings appear under "Block weekends" toggle.
2026-03-29 19:46:24 +02:00
Maurice b28b483b90 fix: unlimited invite links (max_uses=0) no longer blocked as fully used 2026-03-29 19:30:21 +02:00
Maurice 020cafade1 feat: auto-redirect to OIDC when password auth is disabled — closes #94 2026-03-29 18:25:51 +02:00
Maurice e4b2262d4d docs: update README for v2.7.0 — new features, env vars table, fix nomad references 2026-03-29 17:51:03 +02:00
166 changed files with 25153 additions and 1757 deletions
+18
View File
@@ -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
+4
View File
@@ -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
+281
View File
@@ -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)
+12 -14
View File
@@ -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,34 +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
# Server-Dependencies installieren (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 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
# 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"]
+234
View File
@@ -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)
![trip](./docs/screenshot-trip-mcp.png)
+78 -12
View File
@@ -22,7 +22,7 @@
</p> </p>
![TREK Screenshot](docs/screenshot.png) ![TREK Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png) ![TREK Screenshot 2](docs/screenshot-2.png)
<details> <details>
<summary>More Screenshots</summary> <summary>More Screenshots</summary>
@@ -44,11 +44,14 @@
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering - **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps - **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback - **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
- **Map Category Filter** — Filter places by category and see only matching pins on the map
### Travel Management ### Travel Management
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments - **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions - **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding - **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
@@ -61,19 +64,22 @@
### Collaboration ### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users - **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access - **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider - **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities - **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
### Addons (modular, admin-toggleable) ### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking - **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects - **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities - **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### Customization & Admin ### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching - **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Chinese (Simplified), Dutch, Russian (i18n) - **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history - **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention - **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates - **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
@@ -84,7 +90,7 @@
- **PWA**: vite-plugin-pwa + Workbox - **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`) - **Real-Time**: WebSocket (`ws`)
- **State**: Zustand - **State**: Zustand
- **Auth**: JWT + OIDC - **Auth**: JWT + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required) - **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react - **Icons**: lucide-react
@@ -92,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.
@@ -114,15 +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
- 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).
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- 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
# - 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
@@ -151,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).
@@ -163,13 +205,13 @@ For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, T
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name nomad.yourdomain.com; server_name trek.yourdomain.com;
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name nomad.yourdomain.com; server_name trek.yourdomain.com;
ssl_certificate /path/to/fullchain.pem; ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem; ssl_certificate_key /path/to/privkey.pem;
@@ -204,13 +246,36 @@ server {
Caddy handles WebSocket upgrades automatically: Caddy handles WebSocket upgrades automatically:
``` ```
nomad.yourdomain.com { trek.yourdomain.com {
reverse_proxy localhost:3000 reverse_proxy localhost:3000
} }
``` ```
</details> </details>
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `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_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
## Optional API Keys ## Optional API Keys
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed. API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
@@ -226,7 +291,7 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
```bash ```bash
git clone https://github.com/mauriceboe/TREK.git git clone https://github.com/mauriceboe/TREK.git
cd NOMAD cd TREK
docker build -t trek . docker build -t trek .
``` ```
@@ -234,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
+1 -1
View File
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope ## Scope
This policy covers the TREK application and its Docker image (`mauriceboe/nomad`). This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot. Third-party dependencies are monitored via GitHub Dependabot.
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
description: Minimal Helm chart for TREK app
appVersion: "latest"
+34
View File
@@ -0,0 +1,34 @@
# TREK Helm Chart
This is a minimal Helm chart for deploying the TREK app.
## Features
- Deploys the TREK container
- Exposes port 3000 via Service
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
## Usage
```sh
helm install trek ./chart \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
See `values.yaml` for more options.
## Files
- `Chart.yaml` — chart metadata
- `values.yaml` — configuration values
- `templates/` — Kubernetes manifests
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- `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.
- 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.
+23
View File
@@ -0,0 +1,23 @@
1. ENCRYPTION_KEY handling:
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- 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. JWT_SECRET is managed entirely by the server:
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
- 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 a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
+18
View File
@@ -0,0 +1,18 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "trek.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "trek.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "trek.fullname" . }}-config
labels:
app: {{ include "trek.name" . }}
data:
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
PORT: {{ .Values.env.PORT | quote }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
+68
View File
@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ include "trek.name" . }}
template:
metadata:
labels:
app: {{ include "trek.name" . }}
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.imagePullSecrets }}
- name: {{ .name }}
{{- end }}
{{- end }}
securityContext:
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
optional: true
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
pathType: Prefix
backend:
service:
name: {{ include "trek.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
+27
View File
@@ -0,0 +1,27 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.data.size }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
{{- end }}
+29
View File
@@ -0,0 +1,29 @@
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{- 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 }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
protocol: TCP
name: http
selector:
app: {{ include "trek.name" . }}
+68
View File
@@ -0,0 +1,68 @@
image:
repository: mauriceboe/trek
tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
service:
type: ClusterIP
port: 3000
env:
NODE_ENV: production
PORT: 3000
# ALLOWED_ORIGINS: ""
# 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.
# 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:
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
# 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 ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
enabled: true
data:
size: 1Gi
uploads:
size: 1Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
ingress:
enabled: false
annotations: {}
hosts:
- host: chart-example.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
+3 -1
View File
@@ -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>
+17 -17
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.6.2", "version": "2.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.6.2", "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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.7.0", "version": "2.7.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+56 -9
View File
@@ -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'
@@ -11,10 +10,11 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage' import AtlasPage from './pages/AtlasPage'
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
@@ -22,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 (
@@ -40,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 />
} }
@@ -62,16 +75,38 @@ function RootRedirect() {
} }
export default function App() { export default function App() {
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = 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((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
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?.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) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
try {
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
} catch {}
localStorage.setItem('trek_app_version', config.version)
window.location.reload()
return
}
localStorage.setItem('trek_app_version', config.version)
}
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
@@ -83,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)
@@ -99,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>
@@ -107,6 +153,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/register" element={<LoginPage />} /> <Route path="/register" element={<LoginPage />} />
<Route <Route
path="/dashboard" path="/dashboard"
+16
View File
@@ -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
}
}
+50 -10
View File
@@ -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 = {
@@ -91,6 +99,12 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, 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)
},
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 = {
@@ -108,6 +122,7 @@ export const assignmentsApi = {
export const packingApi = { export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
@@ -146,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),
@@ -163,6 +177,13 @@ export const adminApi = {
listInvites: () => apiClient.get('/admin/invites').then(r => r.data), listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
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 }) =>
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 = {
@@ -174,6 +195,7 @@ export const mapsApi = {
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
} }
export const budgetApi = { export const budgetApi = {
@@ -184,6 +206,7 @@ export const budgetApi = {
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
} }
export const filesApi = { export const filesApi = {
@@ -197,6 +220,9 @@ export const filesApi = {
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
} }
export const reservationsApi = { export const reservationsApi = {
@@ -204,6 +230,7 @@ export const reservationsApi = {
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
} }
export const weatherApi = { export const weatherApi = {
@@ -254,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()
@@ -278,4 +304,18 @@ export const backupApi = {
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
} }
export const shareApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').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),
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
}
export default apiClient export default apiClient
+42 -12
View File
@@ -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
+23 -8
View File
@@ -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 } 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, 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>
)
}
@@ -0,0 +1,171 @@
import React, { useCallback, useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { RefreshCw, ClipboardList } from 'lucide-react'
interface AuditEntry {
id: number
created_at: string
user_id: number | null
username: string | null
user_email: string | null
action: string
resource: string | null
details: Record<string, unknown> | null
ip: string | null
}
interface AuditLogPanelProps {
serverTimezone?: string
}
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const limit = 100
const loadFirstPage = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
entries: AuditEntry[]
total: number
}
setEntries(data.entries || [])
setTotal(data.total ?? 0)
setOffset(0)
} catch {
setEntries([])
setTotal(0)
setOffset(0)
} finally {
setLoading(false)
}
}, [])
const loadMore = useCallback(async () => {
const nextOffset = offset + limit
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
entries: AuditEntry[]
total: number
}
setEntries((prev) => [...prev, ...(data.entries || [])])
setTotal(data.total ?? 0)
setOffset(nextOffset)
} catch {
/* keep existing */
} finally {
setLoading(false)
}
}, [offset])
useEffect(() => {
loadFirstPage()
}, [loadFirstPage])
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
} catch {
return iso
}
}
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—'
try {
return JSON.stringify(d)
} catch {
return '—'
}
}
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username
if (e.user_email) return e.user_email
if (e.user_id != null) return `#${e.user_id}`
return '—'
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
</button>
</div>
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
) : (
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<table className="w-full text-sm border-collapse min-w-[720px]">
<thead>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{entries.length < total && (
<button
type="button"
disabled={loading}
onClick={() => loadMore()}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
style={{ color: 'var(--text-secondary)' }}
>
{t('admin.audit.loadMore')}
</button>
)}
</div>
)
}
+90 -5
View File
@@ -3,6 +3,8 @@ import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import CustomSelect from '../shared/CustomSelect'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
{ value: 0, labelKey: 'backup.keep.forever' }, { value: 0, labelKey: 'backup.keep.forever' },
] ]
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
export default function BackupPanel() { export default function BackupPanel() {
const [backups, setBackups] = useState([]) const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null) const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 }) const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => { const loadBackups = async () => {
setIsLoading(true) setIsLoading(true)
@@ -51,6 +69,7 @@ export default function BackupPanel() {
try { try {
const data = await backupApi.getAutoSettings() const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings) setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {} } catch {}
} }
@@ -147,10 +166,12 @@ export default function BackupPanel() {
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return '-' if (!dateStr) return '-'
try { try {
return new Date(dateStr).toLocaleString(locale, { const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
}) }
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr } } catch { return dateStr }
} }
@@ -303,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>
@@ -331,6 +354,68 @@ export default function BackupPanel() {
</div> </div>
</div> </div>
{/* Hour picker (for daily, weekly, monthly) */}
{autoSettings.interval !== 'hourly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
size="sm"
options={HOURS.map(h => {
let label: string
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
label = `${h12}:00 ${period}`
} else {
label = `${String(h).padStart(2, '0')}:00`
}
return { value: String(h), label }
})}
/>
<p className="text-xs text-gray-400 mt-1">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
</p>
</div>
)}
{/* Day of week (for weekly) */}
{autoSettings.interval === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.day_of_week === opt.value
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
)}
{/* Day of month (for monthly) */}
{autoSettings.interval === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
</div>
)}
{/* Keep duration */} {/* Keep duration */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
+8 -4
View File
@@ -18,8 +18,8 @@ export default function GitHubPanel() {
const fetchReleases = async (pageNum = 1, append = false) => { const fetchReleases = async (pageNum = 1, append = false) => {
try { try {
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }) const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = res.data const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data) setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE) setHasMore(data.length === PER_PAGE)
} catch (err: unknown) { } catch (err: unknown) {
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
} }
} }
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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>
)
}
+285 -88
View File
@@ -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 } 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'
@@ -29,8 +31,23 @@ interface PerPersonSummaryEntry {
} }
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] const CURRENCIES = [
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: ', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' } 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
]
const SYMBOLS = {
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
PEN: 'S/.', ARS: 'AR$',
}
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
const fmtNum = (v, locale, cur) => { const fmtNum = (v, locale, cur) => {
@@ -44,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)
@@ -71,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>
) )
@@ -84,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
} }
@@ -94,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)
} }
@@ -117,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>
@@ -145,9 +168,11 @@ interface ChipWithTooltipProps {
label: string label: string
avatarUrl: string | null avatarUrl: string | null
size?: number size?: number
paid?: boolean
onClick?: () => void
} }
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) { function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 }) const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null) const ref = useRef(null)
@@ -160,13 +185,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
setHover(true) setHover(true)
} }
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
return ( return (
<> <>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)} <div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick}
style={{ style={{
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)', width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
}}> }}>
{avatarUrl {avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -177,11 +208,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
<div style={{ <div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 5,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{label} {label}
{paid && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span>
)}
</div>, </div>,
document.body document.body
)} )}
@@ -194,10 +233,12 @@ interface BudgetMemberChipsProps {
members?: BudgetMember[] members?: BudgetMember[]
tripMembers?: TripMember[] tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean compact?: boolean
readOnly?: boolean
} }
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, 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)
@@ -237,16 +278,21 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
{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}
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,
@@ -376,15 +422,25 @@ 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 } = 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 [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
// Load settlement data whenever budget items change
useEffect(() => {
if (!hasMultipleMembers) return
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
}, [tripId, budgetItems, hasMultipleMembers])
const setCurrency = (cur) => { const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur }) if (tripId) updateTrip(tripId, { currency: cur })
} }
@@ -427,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)' }
@@ -439,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>
) )
} }
@@ -461,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' }}>
@@ -475,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}
@@ -487,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>
@@ -509,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>
@@ -531,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 }}>
@@ -539,13 +641,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
members={item.members || []} members={item.members || []}
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)}
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 ? (
@@ -553,29 +657,42 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
members={item.members || []} members={item.members || []}
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)}
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>
@@ -584,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%)',
@@ -621,13 +741,98 @@ 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>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} /> <PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
)} )}
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
<span style={{ display: 'flex', cursor: 'help' }}
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
onClick={e => e.stopPropagation()}
>
<Info size={11} strokeWidth={2} />
</span>
<div style={{
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
}}>
{t('budget.settlementInfo')}
</div>
</span>
</button>
{settlementOpen && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
}}>
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
{fmt(flow.amount, currency)}
</span>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
</div>
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
</div>
))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{t('budget.netBalances')}
</div>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
}}>
{b.avatar_url
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: b.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
fontSize: 11, fontWeight: 600, flexShrink: 0,
color: b.balance > 0 ? '#4ade80' : '#f87171',
}}>
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div> </div>
{pieSegments.length > 0 && ( {pieSegments.length > 0 && (
@@ -641,27 +846,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} /> <PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => { {pieSegments.map(seg => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return ( return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} /> <div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span> <span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
</div> </div>
) )
})} })}
</div> </div>
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => (
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
</div>
))}
</div>
</div> </div>
)} )}
+34 -23
View File
@@ -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>
+29 -16
View File
@@ -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}
+33 -23
View File
@@ -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,17 +4,23 @@ 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 }))
export default function CurrencyWidget() { export default function CurrencyWidget() {
const { t } = useTranslation() const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR') const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD') const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100') const [amount, setAmount] = useState('100')
@@ -40,7 +46,7 @@ export default function CurrencyWidget() {
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => { const formatNumber = (num) => {
if (!num || num === '—') return '—' if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
const result = rawResult const result = rawResult
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react' import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
const POPULAR_ZONES = [ const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' }, { label: 'New York', tz: 'America/New_York' },
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
{ label: 'Cairo', tz: 'Africa/Cairo' }, { label: 'Cairo', tz: 'Africa/Cairo' },
] ]
function getTime(tz, locale) { function getTime(tz, locale, is12h) {
try { try {
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' }) return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' } } catch { return '—' }
} }
@@ -42,6 +43,7 @@ function getOffset(tz) {
export default function TimezoneWidget() { export default function TimezoneWidget() {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [zones, setZones] = useState(() => { const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones') const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [ return saved ? JSON.parse(saved) : [
@@ -87,7 +89,7 @@ export default function TimezoneWidget() {
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz)) const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ') const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST) // Show abbreviated timezone name (e.g. CET, CEST, EST)
@@ -113,7 +115,7 @@ export default function TimezoneWidget() {
{zones.map(z => ( {zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group"> <div key={z.tz} className="flex items-center justify-between group">
<div> <div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale)}</p> <p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p> <p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div> </div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}> <button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
@@ -155,7 +157,7 @@ export default function TimezoneWidget() {
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'}>
<span className="font-medium">{z.label}</span> <span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale)}</span> <span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button> </button>
))} ))}
</div> </div>
+154 -66
View File
@@ -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 }) => {
@@ -302,12 +334,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const renderFileRow = (file: TripFile, isTrash = false) => { const renderFileRow = (file: TripFile, isTrash = false) => {
const FileIcon = getFileIcon(file.mime_type) const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id) const allLinkedPlaceIds = new Set<number>()
const linkedReservation = file.reservation_id if (file.place_id) allLinkedPlaceIds.add(file.place_id)
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title }) for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
: null const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`) // All linked reservations (primary + file_links)
const allLinkedResIds = new Set<number>()
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
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,
@@ -321,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',
@@ -329,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'
@@ -350,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}
@@ -365,12 +400,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>} {file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlace && ( {linkedPlaces.map(p => (
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
)} ))}
{linkedReservation && ( {linkedReservations.map(r => (
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} /> <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
)} ))}
{file.note_id && ( {file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} /> <SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)} )}
@@ -381,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>}
</> </>
) : ( ) : (
<> <>
@@ -396,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>
@@ -477,20 +512,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
} }
} }
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id)) const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place) => ( const placeBtn = (p: Place) => {
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{ const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none', return (
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', <button key={p.id} onClick={async () => {
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400, if (isLinked) {
display: 'flex', alignItems: 'center', gap: 6, if (file.place_id === p.id) {
}} await handleAssign(file.id, { place_id: null })
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} } else {
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}> try {
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} /> const linksRes = await filesApi.getLinks(tripId, file.id)
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span> const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />} if (link) await filesApi.removeLink(tripId, file.id, link.id)
</button> refreshFiles()
) } catch {}
}
} else {
if (!file.place_id) {
await handleAssign(file.id, { place_id: p.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { place_id: p.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const placesSection = places.length > 0 && ( const placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -519,20 +579,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')} {t('files.assignBooking')}
</div> </div>
{reservations.map(r => ( {reservations.map(r => {
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{ const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none', return (
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', <button key={r.id} onClick={async () => {
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400, if (isLinked) {
display: 'flex', alignItems: 'center', gap: 6, // Unlink: if primary reservation_id, clear it; if via file_links, remove link
}} if (file.reservation_id === r.id) {
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} await handleAssign(file.id, { reservation_id: null })
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}> } else {
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} /> try {
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span> const linksRes = await filesApi.getLinks(tripId, file.id)
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />} const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
</button> if (link) await filesApi.removeLink(tripId, file.id, link.id)
))} refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
</div> </div>
) )
@@ -565,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)'}
@@ -580,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>
@@ -618,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',
@@ -647,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',
@@ -672,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' }}>
+7 -15
View File
@@ -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 }) => {
+258 -102
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef, useState, useMemo } from 'react' import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet' import { renderToStaticMarkup } from 'react-dom/server'
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;') return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
} }
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)}" 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,10 +264,99 @@ 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()
export function MapView({ // Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
const map = useMap()
const [position, setPosition] = useState<[number, number] | null>(null)
const [accuracy, setAccuracy] = useState(0)
const [tracking, setTracking] = useState(false)
const watchId = useRef<number | null>(null)
const startTracking = useCallback(() => {
if (!('geolocation' in navigator)) return
setTracking(true)
watchId.current = navigator.geolocation.watchPosition(
(pos) => {
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
setPosition(latlng)
setAccuracy(pos.coords.accuracy)
},
() => setTracking(false),
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}, [])
const stopTracking = useCallback(() => {
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
watchId.current = null
setTracking(false)
setPosition(null)
}, [])
const toggleTracking = useCallback(() => {
if (tracking) { stopTracking() } else { startTracking() }
}, [tracking, startTracking, stopTracking])
// Center map on position when first acquired
const centered = useRef(false)
useEffect(() => {
if (position && !centered.current) {
map.setView(position, 15)
centered.current = true
}
}, [position, map])
// Cleanup on unmount
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
return (
<>
{/* Location button */}
<div style={{
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
}}>
<button onClick={toggleTracking} style={{
width: 36, height: 36, borderRadius: '50%',
border: 'none', cursor: 'pointer',
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.2s, color 0.2s',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
</div>
{/* Blue dot + accuracy circle */}
{position && (
<>
{accuracy < 500 && (
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
)}
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
</>
)}
{/* Pulse animation CSS */}
{position && (
<style>{`
@keyframes location-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
)}
</>
)
}
export const MapView = memo(function MapView({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
route = null, route = null,
@@ -268,39 +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 (Google or Wikimedia Commons fallback) // 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(() => {
places.forEach(place => { if (!places || places.length === 0) return
if (place.image_url) return 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 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 setThumb(cacheKey, cached.thumbDataUrl)
continue
} }
if (mapPhotoInFlight.has(cacheKey)) return
const photoId = place.google_place_id || place.osm_id // Subscribe for when thumb becomes available
if (!photoId && !(place.lat && place.lng)) return cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
mapPhotoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) // Always fetch through API — returns fresh URL + converts to base64
.then(data => { if (!cached && !isLoading(cacheKey)) {
if (data.photoUrl) { const photoId = place.google_place_id || place.osm_id
mapPhotoCache.set(cacheKey, data.photoUrl) if (photoId || (place.lat && place.lng)) {
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
} else { }
mapPhotoCache.set(cacheKey, null) }
} }
mapPhotoInFlight.delete(cacheKey)
}) return () => cleanups.forEach(fn => fn())
.catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) }) }, [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),
}) })
}, [places]) }, [])
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}
@@ -311,6 +498,10 @@ export function MapView({
url={tileUrl} url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <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} />
@@ -318,74 +509,21 @@ export function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LocationTracker />
<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 && (
@@ -402,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>
) )
} })
+1 -1
View File
@@ -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) {
@@ -0,0 +1,855 @@
import { useState, useEffect, useCallback } from '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 { useAuthStore } from '../../store/authStore'
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 ───────────────────────────────────────────────────────────────────
interface TripPhoto {
immich_asset_id: string
user_id: number
username: string
shared: number
added_at: string
}
interface ImmichAsset {
id: string
takenAt: string
city: string | null
country: string | null
}
interface MemoriesPanelProps {
tripId: number
startDate: string | null
endDate: string | null
}
// ── Main Component ──────────────────────────────────────────────────────────
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
// Trip photos (saved selections)
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
// Photo picker
const [showPicker, setShowPicker] = useState(false)
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
const [pickerLoading, setPickerLoading] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Confirm share popup
const [showConfirmShare, setShowConfirmShare] = useState(false)
// Filters & sort
const [sortAsc, setSortAsc] = useState(true)
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
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
// ── Init ──────────────────────────────────────────────────────────────────
useEffect(() => {
loadInitial()
}, [tripId])
// WebSocket: reload photos when another user adds/removes/shares
useEffect(() => {
const handler = () => loadPhotos()
window.addEventListener('memories:updated', handler)
return () => window.removeEventListener('memories:updated', handler)
}, [tripId])
const loadPhotos = async () => {
try {
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
}
}
const loadInitial = async () => {
setLoading(true)
try {
const statusRes = await apiClient.get('/integrations/immich/status')
setConnected(statusRes.data.connected)
} catch {
setConnected(false)
}
await loadPhotos()
await loadAlbumLinks()
setLoading(false)
}
// ── Photo Picker ──────────────────────────────────────────────────────────
const [pickerDateFilter, setPickerDateFilter] = useState(true)
const openPicker = async () => {
setShowPicker(true)
setPickerLoading(true)
setSelectedIds(new Set())
setPickerDateFilter(!!(startDate && endDate))
await loadPickerPhotos(!!(startDate && endDate))
}
const loadPickerPhotos = async (useDate: boolean) => {
setPickerLoading(true)
try {
const res = await apiClient.post('/integrations/immich/search', {
from: useDate && startDate ? startDate : undefined,
to: useDate && endDate ? endDate : undefined,
})
setPickerPhotos(res.data.assets || [])
} catch {
setPickerPhotos([])
} finally {
setPickerLoading(false)
}
}
const togglePickerSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const confirmSelection = () => {
if (selectedIds.size === 0) return
setShowConfirmShare(true)
}
const executeAddPhotos = async () => {
setShowConfirmShare(false)
try {
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
asset_ids: [...selectedIds],
shared: true,
})
setShowPicker(false)
loadInitial()
} catch {}
}
// ── Remove photo ──────────────────────────────────────────────────────────
const removePhoto = async (assetId: string) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
} catch {}
}
// ── Toggle sharing ────────────────────────────────────────────────────────
const toggleSharing = async (assetId: string, shared: boolean) => {
try {
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
setTripPhotos(prev => prev.map(p =>
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch {}
}
// ── Helpers ───────────────────────────────────────────────────────────────
const thumbnailBaseUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
// Unique locations for filter
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
// Apply filter + sort
const allVisible = allVisibleRaw
.filter(p => !locationFilter || p.city === locationFilter)
.sort((a, b) => {
const da = new Date(a.added_at || 0).getTime()
const db = new Date(b.added_at || 0).getTime()
return sortAsc ? da - db : db - da
})
const font: React.CSSProperties = {
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}
// ── Loading ───────────────────────────────────────────────────────────────
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin"
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
)
}
// ── Not connected ─────────────────────────────────────────────────────────
if (!connected && allVisible.length === 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.notConnected')}
</h3>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
{t('memories.notConnectedHint')}
</p>
</div>
)
}
// ── 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) {
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Picker header */}
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.selectPhotos')}
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowPicker(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>
<button onClick={confirmSelection} disabled={selectedIds.size === 0}
style={{
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
}}>
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
</button>
</div>
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 6 }}>
{startDate && endDate && (
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
style={{
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
border: '1px solid', transition: 'all 0.15s',
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
</button>
)}
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
style={{
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
border: '1px solid', transition: 'all 0.15s',
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{t('memories.allPhotos')}
</button>
</div>
{selectedIds.size > 0 && (
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
{selectedIds.size} {t('memories.selected')}
</p>
)}
</div>
{/* Picker grid */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{pickerLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
<div className="w-7 h-7 border-2 rounded-full animate-spin"
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
) : pickerPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
</div>
) : (() => {
// Group photos by month
const byMonth: Record<string, ImmichAsset[]> = {}
for (const asset of pickerPhotos) {
const d = asset.takenAt ? new Date(asset.takenAt) : null
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
if (!byMonth[key]) byMonth[key] = []
byMonth[key].push(asset)
}
const sortedMonths = Object.keys(byMonth).sort().reverse()
return sortedMonths.map(month => (
<div key={month} style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
{month !== 'unknown'
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
: '—'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
{byMonth[month].map(asset => {
const isSelected = selectedIds.has(asset.id)
const isAlready = alreadyAdded.has(asset.id)
return (
<div key={asset.id}
onClick={() => !isAlready && togglePickerSelect(asset.id)}
style={{
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
cursor: isAlready ? 'default' : 'pointer',
opacity: isAlready ? 0.3 : 1,
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Check size={13} color="var(--bg-primary)" />
</div>
)}
{isAlready && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
}}>
{t('memories.alreadyAdded')}
</div>
)}
</div>
)
})}
</div>
</div>
))
})()}
</div>
</div>
{/* Confirm share popup (inside picker) */}
{showConfirmShare && (
<div onClick={() => setShowConfirmShare(false)}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.confirmShareTitle')}
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{t('memories.confirmShareHint', { count: selectedIds.size })}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setShowConfirmShare(false)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={executeAddPhotos}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('memories.confirmShareButton')}
</button>
</div>
</div>
</div>
)}
</>
)
}
// ── Main Gallery ──────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.title')}
</h2>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
{allVisible.length} {t('memories.photosFound')}
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
</p>
</div>
{connected && (
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={openAlbumPicker}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<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>
{/* 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>
{/* Filter & Sort bar */}
{allVisibleRaw.length > 0 && (
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
<button onClick={() => setSortAsc(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
}}>
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
</button>
{locations.length > 1 && (
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
style={{
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">{t('memories.allLocations')}</option>
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
</select>
)}
</div>
)}
{/* Gallery */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{allVisible.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
{t('memories.noPhotos')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
{t('memories.noPhotosHint')}
</p>
<button onClick={openPicker}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={15} /> {t('memories.addPhotos')}
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={photo.immich_asset_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
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)
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}}>
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
{/* Other user's avatar */}
{!isOwn && (
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
<div style={{
width: 22, height: 22, borderRadius: '50%',
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
}}>
{photo.username[0]}
</div>
<div className="memories-avatar-tooltip" style={{
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
}}>
{photo.username}
</div>
</div>
)}
{/* Own photo actions (hover) */}
{isOwn && (
<div className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
</button>
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<X size={12} color="white" />
</button>
</div>
)}
{/* Not shared indicator */}
{isOwn && !photo.shared && (
<div style={{
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('memories.private')}
</div>
)}
</div>
)
})}
</div>
)}
</div>
<style>{`
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
`}</style>
{/* Confirm share popup */}
{showConfirmShare && (
<div onClick={() => setShowConfirmShare(false)}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.confirmShareTitle')}
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{t('memories.confirmShareHint', { count: selectedIds.size })}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setShowConfirmShare(false)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={executeAddPhotos}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('memories.confirmShareButton')}
</button>
</div>
</div>
</div>
)}
{/* Lightbox */}
{lightboxId && lightboxUserId && (
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<X size={20} color="white" />
</button>
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
<img
src={lightboxOriginalSrc}
alt=""
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
/>
{/* Info panel — liquid glass */}
{lightboxInfo && (
<div style={{
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
}}>
{/* Date */}
{lightboxInfo.takenAt && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
</div>
)}
{/* Location */}
{(lightboxInfo.city || lightboxInfo.country) && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
</div>
</div>
)}
{/* Camera */}
{lightboxInfo.camera && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
</div>
)}
{/* Settings */}
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{lightboxInfo.focalLength && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
</div>
)}
{lightboxInfo.aperture && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
</div>
)}
{lightboxInfo.shutter && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
</div>
)}
{lightboxInfo.iso && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
</div>
)}
</div>
)}
{/* Resolution & File */}
{(lightboxInfo.width || lightboxInfo.fileName) && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
{lightboxInfo.width && lightboxInfo.height && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
)}
{lightboxInfo.fileSize && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
)}
</div>
)}
</div>
)}
{lightboxInfoLoading && (
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
</div>
)}
</div>
</div>
)}
</div>
)
}
+41 -2
View File
@@ -12,6 +12,13 @@ function noteIconSvg(iconId) {
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })) return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
} }
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function transportIconSvg(type) {
if (!_renderToStaticMarkup) return ''
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
}
// ── SVG inline icons (for chips) ───────────────────────────────────────────── // ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>` const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>` const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
@@ -96,13 +103,14 @@ interface downloadTripPDFProps {
assignments: AssignmentsMap assignments: AssignmentsMap
categories: Category[] categories: Category[]
dayNotes: DayNotesMap dayNotes: DayNotesMap
reservations?: any[]
t: (key: string, params?: Record<string, string | number>) => string t: (key: string, params?: Record<string, string | number>) => string
locale: string locale: string
} }
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) { export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
await ensureRenderer() await ensureRenderer()
const loc = _locale || 'de-DE' const loc = _locale || undefined
const tr = _t || (k => k) const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc) const range = longDateRange(sorted, loc)
@@ -123,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const notes = (dayNotes || []).filter(n => n.day_id === day.id) const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const dayTransport = (reservations || []).filter(r => {
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayTransport.forEach(r => {
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'transport', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k) merged.sort((a, b) => a.k - b.k)
let pi = 0 let pi = 0
const itemsHtml = merged.length === 0 const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>` ? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => { : merged.map(item => {
if (item.type === 'transport') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = transportIconSvg(r.type)
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return `
<div class="note-card" style="border-left: 3px solid #3b82f6;">
<div class="note-line" style="background: #3b82f6;"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div>
</div>`
}
if (item.type === 'note') { if (item.type === 'note') {
const note = item.data const note = item.data
return ` return `
@@ -1,11 +1,13 @@
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'
import ReactDOM from 'react-dom'
import { import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
} from 'lucide-react' } from 'lucide-react'
import type { PackingItem } from '../../types' import type { PackingItem } from '../../types'
@@ -76,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)
@@ -129,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)}
@@ -139,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',
}} }}
@@ -158,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 {}
@@ -170,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',
}} }}
@@ -246,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
@@ -286,6 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</div> </div>
)}
</div> </div>
) )
} }
@@ -318,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)
@@ -379,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)}
@@ -397,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',
@@ -421,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={{
@@ -478,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
<span style={{ <span style={{
@@ -496,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>
@@ -509,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}
@@ -547,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>
@@ -588,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()
@@ -651,9 +666,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const handleAddNewCategory = async () => { const handleAddNewCategory = async () => {
if (!newCatName.trim()) return if (!newCatName.trim()) return
// Create a first item in the new category to make it appear let catName = newCatName.trim()
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
while (allCategories.includes(catName)) {
catName += '\u200B'
}
try { try {
await addPackingItem(tripId, { name: '...', category: newCatName.trim() }) await addPackingItem(tripId, { name: '...', category: catName })
setNewCatName('') setNewCatName('')
setAddingCategory(false) setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) } } catch { toast.error(t('packing.toast.addError')) }
@@ -723,6 +742,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
const [applyingTemplate, setApplyingTemplate] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null) const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -753,6 +775,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
} }
} }
const parseImportLines = (text: string) => {
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
const parts = line.split(/[,;\t]/).map(s => s.trim())
if (parts.length >= 2) {
const category = parts[0]
const name = parts[1]
const weight_grams = parts[2] || undefined
const bag = parts[3] || undefined
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
return { name, category, weight_grams, bag, checked }
}
// Single value = just a name
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
}).filter(i => i.name)
}
const handleBulkImport = async () => {
const parsed = parseImportLines(importText)
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try {
const result = await packingApi.bulkImport(tripId, parsed)
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) }
}
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const reader = new FileReader()
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
reader.readAsText(file)
}
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
@@ -768,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',
@@ -777,7 +837,16 @@ 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>
)} )}
{availableTemplates.length > 0 && ( {canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{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,
@@ -846,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
@@ -871,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 ── */}
@@ -919,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>
@@ -945,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' }} />
@@ -986,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('') } }}
@@ -1001,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>
@@ -1030,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' }} />
@@ -1071,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('') } }}
@@ -1089,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>
)} )}
@@ -1098,6 +1172,60 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; } .assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
.assignee-chip:hover { opacity: 0.7; } .assignee-chip:hover { opacity: 0.7; }
`}</style> `}</style>
{/* Bulk Import Modal */}
{showImportModal && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowImportModal(false)}>
<div style={{
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 14,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
<button onClick={() => csvInputRef.current?.click()} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowImportModal(false)} 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={handleBulkImport} disabled={!importText.trim()} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
</div>
</div>
</div>
</div>,
document.body
)}
</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'
@@ -50,12 +52,18 @@ interface DayDetailPanelProps {
lng: number | null lng: number | null
onClose: () => void onClose: () => void
onAccommodationChange: () => void onAccommodationChange: () => void
leftWidth?: number
rightWidth?: number
} }
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: 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 fmtTime = (v) => formatTime12(v, is12h) const fmtTime = (v) => formatTime12(v, is12h)
const unit = isFahrenheit ? '°F' : '°C' const unit = isFahrenheit ? '°F' : '°C'
const [weather, setWeather] = useState(null) const [weather, setWeather] = useState(null)
@@ -108,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?.()
@@ -129,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 {}
@@ -146,7 +163,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}> <div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -325,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)' }}>
@@ -368,7 +385,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}> <div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span> <span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
{linked.confirmation_number && <span>#{linked.confirmation_number}</span>} {linked.confirmation_number && <span
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
>#{linked.confirmation_number}</span>}
</div> </div>
</div> </div>
</div> </div>
@@ -377,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 */}
@@ -541,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?.()
File diff suppressed because it is too large Load Diff
@@ -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) {
@@ -104,6 +109,24 @@ export default function PlaceFormModal({
if (!mapsSearch.trim()) return if (!mapsSearch.trim()) return
setIsSearchingMaps(true) setIsSearchingMaps(true)
try { try {
// Detect Google Maps URLs and resolve them directly
const trimmed = mapsSearch.trim()
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
...prev,
name: resolved.name || prev.name,
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
}))
setMapsResults([])
setMapsSearch('')
toast.success(t('places.urlResolved'))
return
}
}
const result = await mapsApi.search(mapsSearch, language) const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || []) setMapsResults(result.places || [])
} catch (err: unknown) { } catch (err: unknown) {
@@ -153,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)) {
@@ -368,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>
+109 -13
View File
@@ -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,16 +119,19 @@ 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
leftWidth?: number
rightWidth?: number
} }
export default function PlaceInspector({ export default function PlaceInspector({
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [], place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
leftWidth = 0, rightWidth = 0,
}: PlaceInspectorProps) { }: PlaceInspectorProps) {
const { t, locale, language } = useTranslation() const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -169,7 +175,7 @@ export default function PlaceInspector({
const selectedDay = days?.find(d => d.id === selectedDayId) const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date) const weekdayIndex = getWeekdayIndex(selectedDay?.date)
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id)) const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
const handleFileUpload = useCallback(async (e) => { const handleFileUpload = useCallback(async (e) => {
const selectedFiles = Array.from((e.target as HTMLInputElement).files || []) const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
@@ -196,9 +202,9 @@ export default function PlaceInspector({
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: 20, bottom: 20,
left: '50%', left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
width: 'min(800px, calc(100vw - 32px))', width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
zIndex: 50, zIndex: 50,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} }}
@@ -336,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>
)} )}
@@ -388,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
@@ -458,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 &nbsp;{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' }}>
@@ -486,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>
)} )}
+285 -67
View File
@@ -1,15 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState } 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 } 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'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
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 {
tripId: number
places: Place[] places: Place[]
categories: Category[] categories: Category[]
assignments: AssignmentsMap assignments: AssignmentsMap
@@ -25,34 +31,81 @@ interface PlacesSidebarProps {
onCategoryFilterChange?: (categoryId: string) => void onCategoryFilterChange?: (categoryId: string) => void
} }
export default function PlacesSidebar({ const PlacesSidebar = React.memo(function PlacesSidebar({
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) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu() const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
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 file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
await loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
} catch (err: any) {
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 [categoryFilter, setCategoryFilterLocal] = useState('') const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
const setCategoryFilter = (val: string) => { const toggleCategoryFilter = (catId: string) => {
setCategoryFilterLocal(val) setCategoryFiltersLocal(prev => {
onCategoryFilterChange?.(val) const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId)
// Notify parent with first selected or empty
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
return next
})
} }
const [dayPickerPlace, setDayPickerPlace] = useState(null) const [dayPickerPlace, setDayPickerPlace] = useState(null)
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 = 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
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
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])
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)
@@ -61,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,
@@ -71,7 +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} />
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
onClick={() => gpxInputRef.current?.click()}
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',
}}
>
<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 }}>
@@ -106,21 +188,69 @@ export default function PlacesSidebar({
)} )}
</div> </div>
{/* Kategoriefilter */} {/* Category multi-select dropdown */}
{categories.length > 0 && ( {categories.length > 0 && (() => {
<div style={{ marginTop: 6 }}> const label = categoryFilters.size === 0
<CustomSelect ? t('places.allCategories')
value={categoryFilter} : categoryFilters.size === 1
onChange={setCategoryFilter} ? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
placeholder={t('places.allCategories')} : `${categoryFilters.size} ${t('places.categoriesSelected')}`
size="sm" return (
options={[ <div style={{ marginTop: 6, position: 'relative' }}>
{ value: '', label: t('places.allCategories') }, <button onClick={() => setCatDropOpen(v => !v)} style={{
...categories.map(c => ({ value: String(c.id), label: c.name })) width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
]} padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
/> background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
</div> cursor: 'pointer', fontFamily: 'inherit',
)} }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{catDropOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
}}>
{categories.map(c => {
const active = categoryFilters.has(String(c.id))
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
textAlign: 'left',
}}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: active ? 'none' : '1.5px solid var(--border-primary)',
background: active ? (c.color || 'var(--accent)') : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <Check size={10} strokeWidth={3} color="white" />}
</div>
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
<span style={{ flex: 1 }}>{c.name}</span>
</button>
)
})}
{categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
marginTop: 2, borderTop: '1px solid var(--border-faint)',
}}>
<X size={10} /> {t('places.clearFilter')}
</button>
)}
</div>
)}
</div>
)
})()}
</div> </div>
{/* Anzahl */} {/* Anzahl */}
@@ -135,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 => {
@@ -157,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,
@@ -178,6 +308,8 @@ export default function PlacesSidebar({
background: isSelected ? 'var(--border-faint)' : 'transparent', background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)', borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s', transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}} }}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
@@ -222,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>,
@@ -273,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
@@ -1,4 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -56,12 +59,14 @@ 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[]
} }
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) { export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
@@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false) const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) const [pendingFiles, setPendingFiles] = useState([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const assignmentOptions = useMemo( const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale), () => buildAssignmentOptions(days, assignments, t, locale),
@@ -204,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} }
} }
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : [] const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = { const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
@@ -459,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> <FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a> <a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
{onFileDelete && ( <button type="button" onClick={async () => {
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}> // Always unlink, never delete the file
<X size={11} /> // Clear primary reservation_id if it points to this reservation
</button> if (f.reservation_id === reservation?.id) {
)} try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
// Remove from file_links if linked there
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div> </div>
))} ))}
{pendingFiles.map((f, i) => ( {pendingFiles.map((f, i) => (
@@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
))} ))}
<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} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px', {onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
}}> fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
<Paperclip size={11} /> }}>
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} <Paperclip size={11} />
</button> {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div> </div>
</div> </div>
@@ -505,5 +573,5 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
function formatDate(dateStr, locale) { function formatDate(dateStr, locale) {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' }) return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
} }
@@ -1,5 +1,7 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
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'
@@ -55,25 +57,29 @@ 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()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const [codeRevealed, setCodeRevealed] = useState(false)
const typeInfo = getType(r.type) const typeInfo = getType(r.type)
const TypeIcon = typeInfo.Icon const TypeIcon = typeInfo.Icon
const confirmed = r.status === 'confirmed' const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id) const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleToggle = async () => { const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) } try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) } catch { toast.error(t('reservations.toast.updateError')) }
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return setShowDeleteConfirm(false)
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
} }
@@ -91,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={handleDelete} 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 */}
@@ -127,14 +143,26 @@ 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>
)} )}
{r.confirmation_number && ( {r.confirmation_number && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}> <div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div> <div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
style={{
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{r.confirmation_number}
</div>
</div> </div>
)} )}
</div> </div>
@@ -227,6 +255,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div> </div>
</div> </div>
)} )}
{/* Delete confirmation popup */}
{showDeleteConfirm && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
width: 340, background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
}}>
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{t('reservations.confirm.deleteTitle')}
</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('reservations.confirm.deleteBody', { name: r.title })}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={() => setShowDeleteConfirm(false)} 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={handleDelete} style={{
fontSize: 12, background: '#ef4444', color: 'white',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>{t('common.confirm')}</button>
</div>
</div>
</div>,
document.body
)}
</div> </div>
) )
} }
@@ -274,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])
@@ -292,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 */}
@@ -314,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>
)} )}
+87 -14
View File
@@ -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>
) )
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { tripsApi, authApi } 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 { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
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'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -32,6 +34,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
) )
} }
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const [shareToken, setShareToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast()
useEffect(() => {
shareApi.getLink(tripId).then(d => {
setShareToken(d.token)
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
setLoading(false)
}).catch(() => setLoading(false))
}, [tripId])
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
const handleCreate = async () => {
try {
const d = await shareApi.createLink(tripId, perms)
setShareToken(d.token)
} catch { toast.error(t('share.createError')) }
}
const handleUpdatePerms = async (key: string, val: boolean) => {
const newPerms = { ...perms, [key]: val }
setPerms(newPerms)
if (shareToken) {
try { await shareApi.createLink(tripId, newPerms) } catch {}
}
}
const handleDelete = async () => {
try {
await shareApi.deleteLink(tripId)
setShareToken(null)
} catch {}
}
const handleCopy = () => {
if (shareUrl) {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (loading) return null
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
</div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
{/* Permission checkboxes */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{[
{ key: 'share_map', label: t('share.permMap'), always: true },
{ key: 'share_bookings', label: t('share.permBookings') },
{ key: 'share_packing', label: t('share.permPacking') },
{ key: 'share_budget', label: t('share.permBudget') },
{ key: 'share_collab', label: t('share.permCollab') },
].map(opt => (
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s',
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
opacity: opt.always ? 0.7 : 1,
}}>
{perms[opt.key] ? <Check size={10} /> : null}
{opt.label}
</button>
))}
</div>
{shareUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
}}>
<input type="text" value={shareUrl} readOnly style={{
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
outline: 'none', fontFamily: 'monospace',
}} />
<button onClick={handleCopy} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
}}>
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
</button>
</div>
<button onClick={handleDelete} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={11} /> {t('share.deleteLink')}
</button>
</div>
) : (
<button onClick={handleCreate} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={12} /> {t('share.createLink')}
</button>
)}
</div>
)
}
interface TripMembersModalProps { interface TripMembersModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
@@ -49,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) {
@@ -123,8 +252,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
] : [] ] : []
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm"> <Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <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>
{/* Left column: Members */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Trip name */} {/* Trip name */}
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}> <div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
@@ -133,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>
@@ -166,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>
@@ -190,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,
@@ -228,6 +361,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
)} )}
</div> </div>
</div>
{/* Right column: Share Link */}
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} />
</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>
</Modal> </Modal>
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
}, [entries]) }, [entries])
const blockWeekends = plan?.block_weekends !== false const blockWeekends = plan?.block_weekends !== false
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
const handleCellClick = useCallback(async (dateStr) => { const handleCellClick = useCallback(async (dateStr) => {
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
return return
} }
if (holidays[dateStr]) return if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr)) return if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined) await toggleEntry(dateStr, selectedUserId || undefined)
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId]) }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
onCellClick={handleCellClick} onCellClick={handleCellClick}
companyMode={companyMode} companyMode={companyMode}
blockWeekends={blockWeekends} blockWeekends={blockWeekends}
weekendDays={weekendDays}
/> />
))} ))}
</div> </div>
+10 -14
View File
@@ -3,14 +3,7 @@ import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays' import { isWeekend } from './holidays'
import type { HolidaysMap, VacayEntry } from '../../types' import type { HolidaysMap, VacayEntry } from '../../types'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
function hexToRgba(hex: string, alpha: number): string { function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16) const r = parseInt(hex.slice(1, 3), 16)
@@ -29,16 +22,18 @@ interface VacayMonthCardProps {
onCellClick: (date: string) => void onCellClick: (date: string) => void
companyMode: boolean companyMode: boolean
blockWeekends: boolean blockWeekends: boolean
weekendDays?: number[]
} }
export default function VacayMonthCard({ export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
}: VacayMonthCardProps) { }: VacayMonthCardProps) {
const { language } = useTranslation() const { t, locale } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN
const weekdays = WEEKDAY_KEYS.map(k => t(k))
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
const weeks = useMemo(() => { const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
@@ -58,7 +53,7 @@ export default function VacayMonthCard({
return ( return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span> <span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
</div> </div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
@@ -76,7 +71,8 @@ export default function VacayMonthCard({
if (day === null) return <div key={di} style={{ height: 28 }} /> if (day === null) return <div key={di} style={{ height: 28 }} />
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}` const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
const weekend = di >= 5 const dayOfWeek = new Date(year, month, day).getDay()
const weekend = weekendDays.includes(dayOfWeek)
const holiday = holidays[dateStr] const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr) const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || [] const dayEntries = entryMap[dateStr] || []
@@ -49,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
onChange={() => toggle('block_weekends')} onChange={() => toggle('block_weekends')}
/> />
{/* Weekend days selector */}
{plan.block_weekends !== false && (
<div style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5">
{[
{ day: 1, label: t('vacay.mon') },
{ day: 2, label: t('vacay.tue') },
{ day: 3, label: t('vacay.wed') },
{ day: 4, label: t('vacay.thu') },
{ day: 5, label: t('vacay.fri') },
{ day: 6, label: t('vacay.sat') },
{ day: 0, label: t('vacay.sun') },
].map(({ day, label }) => {
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const active = current.includes(day)
return (
<button key={day} onClick={() => {
const next = active ? current.filter(d => d !== day) : [...current, day]
updatePlan({ weekend_days: next.join(',') })
}}
style={{
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{label}
</button>
)
})}
</div>
</div>
)}
{/* Carry-over */} {/* Carry-over */}
<SettingToggle <SettingToggle
icon={ArrowRightLeft} icon={ArrowRightLeft}
+4 -5
View File
@@ -103,10 +103,9 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
return holidays return holidays
} }
export function isWeekend(dateStr: string): boolean { export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T00:00:00')
const day = d.getDay() return weekendDays.includes(d.getDay())
return day === 0 || day === 6
} }
export function getWeekday(dateStr: string): string { export function getWeekday(dateStr: string): string {
@@ -123,9 +122,9 @@ export function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate() return new Date(year, month, 0).getDate()
} }
export function formatDate(dateStr: string): string { export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }) return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
} }
export { BUNDESLAENDER } export { BUNDESLAENDER }
@@ -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>
)} )}
+16 -6
View File
@@ -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>}
@@ -107,9 +111,15 @@ export default function CustomSelect({
{open && ReactDOM.createPortal( {open && ReactDOM.createPortal(
<div ref={dropRef} style={{ <div ref={dropRef} style={{
position: 'fixed', position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(), ...(() => {
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(), const r = ref.current?.getBoundingClientRect()
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(), if (!r) return { top: 0, left: 0, width: 200 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < 220 && r.top > spaceBelow
return openUp
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
: { top: r.bottom + 4, left: r.left, width: r.width }
})(),
zIndex: 99999, zIndex: 99999,
background: 'var(--bg-card)', background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)', backdropFilter: 'blur(24px) saturate(180%)',
@@ -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
View File
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
lg: 'max-w-lg', lg: 'max-w-lg',
xl: 'max-w-2xl', xl: 'max-w-2xl',
'2xl': 'max-w-4xl', '2xl': 'max-w-4xl',
'3xl': 'max-w-5xl',
} }
interface ModalProps { interface ModalProps {
+40 -35
View File
@@ -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,48 +14,52 @@ interface PlaceAvatarProps {
category?: Category | null category?: Category | null
} }
const photoCache = new Map<string, string | null>() export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const photoInFlight = new Set<string>()
export default 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)) {
// Another instance is already fetching, wait for it return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
const check = setInterval(() => {
if (photoCache.has(cacheKey)) {
clearInterval(check)
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
}
}, 200)
return () => clearInterval(check)
} }
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
.then((data: { photoUrl?: string }) => { entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
if (data.photoUrl) { )
photoCache.set(cacheKey, data.photoUrl) return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
setPhotoSrc(data.photoUrl) }, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
} else {
photoCache.set(cacheKey, null)
}
photoInFlight.delete(cacheKey)
})
.catch(() => { photoCache.set(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)
@@ -72,10 +76,11 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
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}
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)} onError={() => setPhotoSrc(null)}
/> />
@@ -84,8 +89,8 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
} }
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>
) )
} })
+6 -3
View File
@@ -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 }
} }
+12 -3
View File
@@ -4,10 +4,14 @@ import de from './translations/de'
import en from './translations/en' import en from './translations/en'
import es from './translations/es' import es from './translations/es'
import fr from './translations/fr' import fr from './translations/fr'
import hu from './translations/hu'
import it from './translations/it'
import ru from './translations/ru' import ru from './translations/ru'
import zh from './translations/zh' import zh from './translations/zh'
import nl from './translations/nl' import nl from './translations/nl'
import ar from './translations/ar' import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
type TranslationStrings = Record<string, string | { name: string; category: string }[]> type TranslationStrings = Record<string, string | { name: string; category: string }[]>
@@ -16,14 +20,18 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'hu', label: 'Magyar' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
{ value: 'cs', label: 'Česky' },
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' }, { value: 'zh', label: '中文' },
{ value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' }, { value: 'ar', label: 'العربية' },
] as const ] as const
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar } const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' } const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
const RTL_LANGUAGES = new Set(['ar']) const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string { export function getLocaleForLanguage(language: string): string {
@@ -31,7 +39,8 @@ export function getLocaleForLanguage(language: string): string {
} }
export function getIntlLanguage(language: string): string { export function getIntlLanguage(language: string): string {
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en' if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
} }
export function isRtlLanguage(language: string): boolean { export function isRtlLanguage(language: string): boolean {
+308 -6
View File
@@ -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': 'جارٍ الرفع...',
@@ -144,8 +153,94 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'وحدة الحرارة', 'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت', 'settings.timeFormat': 'تنسيق الوقت',
'settings.routeCalculation': 'حساب المسار', 'settings.routeCalculation': 'حساب المسار',
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.notifications': 'الإشعارات',
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'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.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
'admin.smtp.testButton': 'إرسال بريد تجريبي',
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
'share.linkTitle': 'رابط عام',
'share.linkHint': 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.',
'share.createLink': 'إنشاء رابط',
'share.deleteLink': 'حذف الرابط',
'share.createError': 'تعذر إنشاء الرابط',
'common.copy': 'نسخ',
'common.copied': 'تم النسخ',
'share.permMap': 'الخريطة والخطة',
'share.permBookings': 'الحجوزات',
'share.permPacking': 'الأمتعة',
'shared.expired': 'الرابط منتهي أو غير صالح',
'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
'shared.readOnly': 'عرض للقراءة فقط',
'shared.tabPlan': 'الخطة',
'shared.tabBookings': 'الحجوزات',
'shared.tabPacking': 'قائمة التعبئة',
'shared.tabBudget': 'الميزانية',
'shared.tabChat': 'الدردشة',
'shared.days': 'أيام',
'shared.places': 'أماكن',
'shared.other': 'أخرى',
'shared.totalBudget': 'إجمالي الميزانية',
'shared.messages': 'رسائل',
'shared.sharedVia': 'تمت المشاركة عبر',
'shared.confirmed': 'مؤكد',
'shared.pending': 'قيد الانتظار',
'share.permBudget': 'الميزانية',
'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': 'البريد الإلكتروني',
@@ -153,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': 'كلمة المرور الجديدة',
@@ -161,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': 'هل تريد حذف حسابك؟',
@@ -182,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': 'إعداد المصادقة',
@@ -224,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': 'جارٍ الإنشاء…',
@@ -250,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': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
@@ -276,10 +382,25 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'المستخدمون', 'admin.tabs.users': 'المستخدمون',
'admin.tabs.categories': 'الفئات', 'admin.tabs.categories': 'الفئات',
'admin.tabs.backup': 'النسخ الاحتياطي', 'admin.tabs.backup': 'النسخ الاحتياطي',
'admin.tabs.audit': 'سجل التدقيق',
'admin.tabs.settings': 'الإعدادات', 'admin.tabs.settings': 'الإعدادات',
'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': 'الرحلات',
@@ -329,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',
@@ -380,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': 'الميزانية',
@@ -400,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': 'لا توجد إضافات متاحة',
@@ -419,6 +546,18 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.', 'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
// GitHub // GitHub
'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
'admin.audit.refresh': 'تحديث',
'admin.audit.loadMore': 'تحميل المزيد',
'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}',
'admin.audit.col.time': 'الوقت',
'admin.audit.col.user': 'المستخدم',
'admin.audit.col.action': 'الإجراء',
'admin.audit.col.resource': 'المورد',
'admin.audit.col.ip': 'عنوان IP',
'admin.audit.col.details': 'التفاصيل',
'admin.github.title': 'سجل الإصدارات', 'admin.github.title': 'سجل الإصدارات',
'admin.github.subtitle': 'آخر التحديثات من {repo}', 'admin.github.subtitle': 'آخر التحديثات من {repo}',
'admin.github.latest': 'الأحدث', 'admin.github.latest': 'الأحدث',
@@ -482,6 +621,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.carriedOver': 'من {year}', 'vacay.carriedOver': 'من {year}',
'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع', 'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع',
'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد', 'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد',
'vacay.weekendDays': 'أيام عطلة نهاية الأسبوع',
'vacay.mon': 'الاثنين',
'vacay.tue': 'الثلاثاء',
'vacay.wed': 'الأربعاء',
'vacay.thu': 'الخميس',
'vacay.fri': 'الجمعة',
'vacay.sat': 'السبت',
'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية', 'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم', 'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.selectCountry': 'اختر الدولة', 'vacay.selectCountry': 'اختر الدولة',
@@ -539,12 +686,16 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.markVisited': 'تعيين كمُزار', 'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة', 'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات', 'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.searchCountry': 'ابحث عن دولة...',
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته', 'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.bucketWhen': 'متى تخطط للزيارة؟', 'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'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': 'أضف أماكن تحلم بزيارتها',
@@ -557,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': 'سنوات متتالية',
@@ -587,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': 'تم تحديث المكان',
@@ -624,14 +775,31 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF', 'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfError': 'فشل تصدير PDF', 'dayplan.pdfError': 'فشل تصدير PDF',
'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
// Places Sidebar // Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط', 'places.addPlace': 'إضافة مكان/نشاط',
'places.importGpx': 'GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
'places.importGoogleList': 'قائمة Google',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
'places.viewDetails': 'عرض التفاصيل',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟', 'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل', 'places.all': 'الكل',
'places.unplanned': 'غير مخطط', 'places.unplanned': 'غير مخطط',
'places.search': 'ابحث عن أماكن...', 'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات', 'places.allCategories': 'كل الفئات',
'places.categoriesSelected': 'فئات',
'places.clearFilter': 'مسح الفلتر',
'places.count': '{count} أماكن', 'places.count': '{count} أماكن',
'places.countSingular': 'مكان واحد', 'places.countSingular': 'مكان واحد',
'places.allPlanned': 'تم تخطيط جميع الأماكن', 'places.allPlanned': 'تم تخطيط جميع الأماكن',
@@ -681,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': 'الحجوزات',
@@ -731,6 +900,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.tour': 'جولة', 'reservations.type.tour': 'جولة',
'reservations.type.other': 'أخرى', 'reservations.type.other': 'أخرى',
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟', 'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
'reservations.confirm.deleteTitle': 'حذف الحجز؟',
'reservations.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
'reservations.toast.updated': 'تم تحديث الحجز', 'reservations.toast.updated': 'تم تحديث الحجز',
'reservations.toast.removed': 'تم حذف الحجز', 'reservations.toast.removed': 'تم حذف الحجز',
'reservations.toast.fileUploaded': 'تم رفع الملف', 'reservations.toast.fileUploaded': 'تم رفع الملف',
@@ -750,6 +921,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.pendingSave': 'سيتم الحفظ…', 'reservations.pendingSave': 'سيتم الحفظ…',
'reservations.uploading': 'جارٍ الرفع...', 'reservations.uploading': 'جارٍ الرفع...',
'reservations.attachFile': 'إرفاق ملف', 'reservations.attachFile': 'إرفاق ملف',
'reservations.linkExisting': 'ربط ملف موجود',
'reservations.toast.saveError': 'فشل الحفظ', 'reservations.toast.saveError': 'فشل الحفظ',
'reservations.toast.updateError': 'فشل التحديث', 'reservations.toast.updateError': 'فشل التحديث',
'reservations.toast.deleteError': 'فشل الحذف', 'reservations.toast.deleteError': 'فشل الحذف',
@@ -760,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': 'أدخل اسم الفئة...',
@@ -774,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': 'فئة جديدة',
@@ -787,6 +961,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.paid': 'مدفوع', 'budget.paid': 'مدفوع',
'budget.open': 'مفتوح', 'budget.open': 'مفتوح',
'budget.noMembers': 'لا أعضاء معينون', 'budget.noMembers': 'لا أعضاء معينون',
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
// Files // Files
'files.title': 'الملفات', 'files.title': 'الملفات',
@@ -840,6 +1017,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Packing // Packing
'packing.title': 'قائمة التجهيز', 'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة', 'packing.empty': 'قائمة التجهيز فارغة',
'packing.import': 'استيراد',
'packing.importTitle': 'استيراد قائمة التعبئة',
'packing.importHint': 'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
'packing.importPlaceholder': 'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 5\nجواز سفر، مستندات',
'packing.importCsv': 'تحميل CSV/TXT',
'packing.importAction': 'استيراد {count}',
'packing.importSuccess': 'تم استيراد {count} عنصر',
'packing.importError': 'فشل الاستيراد',
'packing.importEmpty': 'لا توجد عناصر للاستيراد',
'packing.progress': '{packed} من {total} جُهّز ({percent}%)', 'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
'packing.clearChecked': 'إزالة {count} محدد', 'packing.clearChecked': 'إزالة {count} محدد',
'packing.clearCheckedShort': 'إزالة {count}', 'packing.clearCheckedShort': 'إزالة {count}',
@@ -991,7 +1177,27 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'تفعيل النسخ التلقائي', 'backup.auto.enable': 'تفعيل النسخ التلقائي',
'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار', 'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
'backup.auto.interval': 'الفترة', 'backup.auto.interval': 'الفترة',
'backup.auto.hour': 'التنفيذ في الساعة',
'backup.auto.hourHint': 'التوقيت المحلي للخادم (تنسيق {format})',
'backup.auto.dayOfWeek': 'يوم الأسبوع',
'backup.auto.dayOfMonth': 'يوم الشهر',
'backup.auto.dayOfMonthHint': 'محدود بين 1–28 للتوافق مع جميع الأشهر',
'backup.auto.scheduleSummary': 'الجدول',
'backup.auto.summaryDaily': 'كل يوم الساعة {hour}:00',
'backup.auto.summaryWeekly': 'كل {day} الساعة {hour}:00',
'backup.auto.summaryMonthly': 'اليوم {day} من كل شهر الساعة {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'النسخ الاحتياطي التلقائي مُعدّ عبر متغيرات بيئة Docker. لتعديل الإعدادات، حدّث docker-compose.yml وأعد تشغيل الحاوية.',
'backup.auto.copyEnv': 'نسخ متغيرات بيئة Docker',
'backup.auto.envCopied': 'تم نسخ متغيرات بيئة Docker إلى الحافظة',
'backup.auto.keepLabel': 'حذف النسخ القديمة بعد', 'backup.auto.keepLabel': 'حذف النسخ القديمة بعد',
'backup.dow.sunday': 'أحد',
'backup.dow.monday': 'إثن',
'backup.dow.tuesday': 'ثلا',
'backup.dow.wednesday': 'أرب',
'backup.dow.thursday': 'خمي',
'backup.dow.friday': 'جمع',
'backup.dow.saturday': 'سبت',
'backup.interval.hourly': 'كل ساعة', 'backup.interval.hourly': 'كل ساعة',
'backup.interval.daily': 'يوميًا', 'backup.interval.daily': 'يوميًا',
'backup.interval.weekly': 'أسبوعيًا', 'backup.interval.weekly': 'أسبوعيًا',
@@ -1118,6 +1324,52 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'day.editAccommodation': 'تعديل الإقامة', 'day.editAccommodation': 'تعديل الإقامة',
'day.reservations': 'الحجوزات', 'day.reservations': 'الحجوزات',
// Memories / Immich
'memories.title': 'صور',
'memories.notConnected': 'Immich غير متصل',
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
'memories.photosFound': 'صور',
'memories.fromOthers': 'من آخرين',
'memories.sharePhotos': 'مشاركة الصور',
'memories.sharing': 'مشترك',
'memories.reviewTitle': 'مراجعة صورك',
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
'memories.shareCount': 'مشاركة {count} صور',
'memories.immichUrl': 'عنوان خادم Immich',
'memories.immichApiKey': 'مفتاح API',
'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich',
'memories.saved': 'تم حفظ إعدادات Immich',
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich',
'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور',
'memories.alreadyAdded': 'تمت الإضافة',
'memories.private': 'خاص',
'memories.stopSharing': 'إيقاف المشاركة',
'memories.tripDates': 'تواريخ الرحلة',
'memories.allPhotos': 'جميع الصور',
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'الدردشة', 'collab.tabs.chat': 'الدردشة',
'collab.tabs.notes': 'الملاحظات', 'collab.tabs.notes': 'الملاحظات',
@@ -1136,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} د',
@@ -1186,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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+312 -7
View File
@@ -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…',
@@ -139,8 +148,94 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperatureinheit', 'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat', 'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung', 'settings.routeCalculation': 'Routenberechnung',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
'settings.notifications': 'Benachrichtigungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'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.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
'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.testFailed': 'Test-E-Mail fehlgeschlagen',
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
'share.linkTitle': 'Öffentlicher Link',
'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
'share.createLink': 'Link erstellen',
'share.deleteLink': 'Link löschen',
'share.createError': 'Link konnte nicht erstellt werden',
'common.copy': 'Kopieren',
'common.copied': 'Kopiert',
'share.permMap': 'Karte & Plan',
'share.permBookings': 'Buchungen',
'share.permPacking': 'Packliste',
'shared.expired': 'Link abgelaufen oder ungültig',
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
'shared.readOnly': 'Nur-Lesen Ansicht',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Buchungen',
'shared.tabPacking': 'Packliste',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'Tage',
'shared.places': 'Orte',
'shared.other': 'Sonstige',
'shared.totalBudget': 'Gesamtbudget',
'shared.messages': 'Nachrichten',
'shared.sharedVia': 'Geteilt über',
'shared.confirmed': 'Bestätigt',
'shared.pending': 'Ausstehend',
'share.permBudget': 'Budget',
'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',
@@ -148,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',
@@ -156,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?',
@@ -177,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',
@@ -219,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…',
@@ -245,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.',
@@ -271,6 +377,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'Benutzer', 'admin.tabs.users': 'Benutzer',
'admin.tabs.categories': 'Kategorien', 'admin.tabs.categories': 'Kategorien',
'admin.tabs.backup': 'Backup', 'admin.tabs.backup': 'Backup',
'admin.tabs.audit': 'Audit-Protokoll',
'admin.stats.users': 'Benutzer', 'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen', 'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte', 'admin.stats.places': 'Orte',
@@ -320,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',
@@ -374,8 +483,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.catalog.memories.name': 'Erinnerungen',
'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
'admin.addons.catalog.packing.name': 'Packliste', 'admin.addons.catalog.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise', 'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.budget.name': 'Budget', 'admin.addons.catalog.budget.name': 'Budget',
@@ -388,14 +495,20 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken', 'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.name': 'Collab',
'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.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',
@@ -411,8 +524,37 @@ 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',
'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
'admin.audit.empty': 'Noch keine Audit-Einträge.',
'admin.audit.refresh': 'Aktualisieren',
'admin.audit.loadMore': 'Mehr laden',
'admin.audit.showing': '{count} geladen · {total} gesamt',
'admin.audit.col.time': 'Zeit',
'admin.audit.col.user': 'Benutzer',
'admin.audit.col.action': 'Aktion',
'admin.audit.col.resource': 'Ressource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Update-Verlauf', 'admin.github.title': 'Update-Verlauf',
'admin.github.subtitle': 'Neueste Updates von {repo}', 'admin.github.subtitle': 'Neueste Updates von {repo}',
'admin.github.latest': 'Aktuell', 'admin.github.latest': 'Aktuell',
@@ -475,7 +617,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'vacay.remaining': 'Rest', 'vacay.remaining': 'Rest',
'vacay.carriedOver': 'aus {year}', 'vacay.carriedOver': 'aus {year}',
'vacay.blockWeekends': 'Wochenenden sperren', 'vacay.blockWeekends': 'Wochenenden sperren',
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen', 'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
'vacay.weekendDays': 'Wochenendtage',
'vacay.mon': 'Mo',
'vacay.tue': 'Di',
'vacay.wed': 'Mi',
'vacay.thu': 'Do',
'vacay.fri': 'Fr',
'vacay.sat': 'Sa',
'vacay.sun': 'So',
'vacay.publicHolidays': 'Feiertage', 'vacay.publicHolidays': 'Feiertage',
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren', 'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
'vacay.selectCountry': 'Land wählen', 'vacay.selectCountry': 'Land wählen',
@@ -534,12 +684,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.markVisited': 'Als besucht markieren', 'atlas.markVisited': 'Als besucht markieren',
'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.searchCountry': 'Land suchen...',
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
'atlas.month': 'Monat',
'atlas.year': 'Jahr',
'atlas.addToBucketHint': 'Als Wunschziel speichern', 'atlas.addToBucketHint': 'Als Wunschziel speichern',
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?', 'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
'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',
@@ -552,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',
@@ -582,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',
@@ -597,6 +751,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Day Plan Sidebar // Day Plan Sidebar
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant', 'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
'dayplan.addNote': 'Notiz hinzufügen', 'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten', 'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen', 'dayplan.noteAdd': 'Notiz hinzufügen',
@@ -622,11 +782,22 @@ 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',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.urlResolved': 'Ort aus URL importiert',
'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',
'places.search': 'Orte suchen...', 'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien', 'places.allCategories': 'Alle Kategorien',
'places.categoriesSelected': 'Kategorien',
'places.clearFilter': 'Filter zurücksetzen',
'places.count': '{count} Orte', 'places.count': '{count} Orte',
'places.countSingular': '1 Ort', 'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant', 'places.allPlanned': 'Alle Orte sind eingeplant',
@@ -675,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',
@@ -725,6 +897,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.tour': 'Tour', 'reservations.type.tour': 'Tour',
'reservations.type.other': 'Sonstiges', 'reservations.type.other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?', 'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
'reservations.confirm.deleteTitle': 'Buchung löschen?',
'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
'reservations.toast.updated': 'Reservierung aktualisiert', 'reservations.toast.updated': 'Reservierung aktualisiert',
'reservations.toast.removed': 'Reservierung gelöscht', 'reservations.toast.removed': 'Reservierung gelöscht',
'reservations.toast.saveError': 'Fehler beim Speichern', 'reservations.toast.saveError': 'Fehler beim Speichern',
@@ -748,12 +922,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.pendingSave': 'wird gespeichert…', 'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...', 'reservations.uploading': 'Wird hochgeladen...',
'reservations.attachFile': 'Datei anhängen', 'reservations.attachFile': 'Datei anhängen',
'reservations.linkExisting': 'Vorhandene verknüpfen',
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen', 'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...', 'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
'reservations.noAssignment': 'Keine Verknüpfung', 'reservations.noAssignment': 'Keine Verknüpfung',
// 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...',
@@ -768,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',
@@ -781,6 +958,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'budget.paid': 'Bezahlt', 'budget.paid': 'Bezahlt',
'budget.open': 'Offen', 'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen', 'budget.noMembers': 'Keine Teilnehmer zugewiesen',
'budget.settlement': 'Ausgleich',
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
'budget.netBalances': 'Netto-Salden',
// Files // Files
'files.title': 'Dateien', 'files.title': 'Dateien',
@@ -834,6 +1014,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Packing // Packing
'packing.title': 'Packliste', 'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer', 'packing.empty': 'Packliste ist leer',
'packing.import': 'Importieren',
'packing.importTitle': 'Packliste importieren',
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importieren',
'packing.importSuccess': '{count} Einträge importiert',
'packing.importError': 'Import fehlgeschlagen',
'packing.importEmpty': 'Keine Einträge zum Importieren',
'packing.progress': '{packed} von {total} gepackt ({percent}%)', 'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen', 'packing.clearChecked': '{count} abgehakte entfernen',
'packing.clearCheckedShort': '{count} entfernen', 'packing.clearCheckedShort': '{count} entfernen',
@@ -985,7 +1174,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'Auto-Backup aktivieren', 'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt', 'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall', 'backup.auto.interval': 'Intervall',
'backup.auto.hour': 'Ausführung um',
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
'backup.auto.dayOfWeek': 'Wochentag',
'backup.auto.dayOfMonth': 'Tag des Monats',
'backup.auto.dayOfMonthHint': 'Auf 128 beschränkt, um mit allen Monaten kompatibel zu sein',
'backup.auto.scheduleSummary': 'Zeitplan',
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
'backup.auto.keepLabel': 'Alte Backups löschen nach', 'backup.auto.keepLabel': 'Alte Backups löschen nach',
'backup.dow.sunday': 'So',
'backup.dow.monday': 'Mo',
'backup.dow.tuesday': 'Di',
'backup.dow.wednesday': 'Mi',
'backup.dow.thursday': 'Do',
'backup.dow.friday': 'Fr',
'backup.dow.saturday': 'Sa',
'backup.interval.hourly': 'Stündlich', 'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich', 'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich', 'backup.interval.weekly': 'Wöchentlich',
@@ -1112,6 +1321,52 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'day.editAccommodation': 'Unterkunft bearbeiten', 'day.editAccommodation': 'Unterkunft bearbeiten',
'day.reservations': 'Reservierungen', 'day.reservations': 'Reservierungen',
// Photos / Immich
'memories.title': 'Fotos',
'memories.notConnected': 'Immich nicht verbunden',
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
'memories.noPhotos': 'Keine Fotos gefunden',
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
'memories.photosFound': 'Fotos',
'memories.fromOthers': 'von anderen',
'memories.sharePhotos': 'Fotos teilen',
'memories.sharing': 'Wird geteilt',
'memories.reviewTitle': 'Deine Fotos prüfen',
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
'memories.shareCount': '{count} Fotos teilen',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-Schlüssel',
'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden',
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
'memories.saved': 'Immich-Einstellungen gespeichert',
'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.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt',
'memories.addSelected': '{count} Fotos hinzufügen',
'memories.alreadyAdded': 'Hinzugefügt',
'memories.private': 'Privat',
'memories.stopSharing': 'Nicht mehr teilen',
'memories.oldest': 'Älteste zuerst',
'memories.newest': 'Neueste zuerst',
'memories.allLocations': 'Alle Orte',
'memories.tripDates': 'Trip-Zeitraum',
'memories.allPhotos': 'Alle Fotos',
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
'memories.confirmShareButton': 'Fotos teilen',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notizen', 'collab.tabs.notes': 'Notizen',
@@ -1130,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.',
@@ -1180,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
+309 -7
View File
@@ -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…',
@@ -139,8 +148,94 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperature Unit', 'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format', 'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation', 'settings.routeCalculation': 'Route Calculation',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
'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.hint': 'SMTP configuration for sending email notifications.',
'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.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)',
'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.createLink': 'Create link',
'share.deleteLink': 'Delete link',
'share.createError': 'Could not create link',
'common.copy': 'Copy',
'common.copied': 'Copied',
'share.permMap': 'Map & Plan',
'share.permBookings': 'Bookings',
'share.permPacking': 'Packing',
'shared.expired': 'Link expired or invalid',
'shared.expiredHint': 'This shared trip link is no longer active.',
'shared.readOnly': 'Read-only shared view',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Bookings',
'shared.tabPacking': 'Packing',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'days',
'shared.places': 'places',
'shared.other': 'Other',
'shared.totalBudget': 'Total Budget',
'shared.messages': 'messages',
'shared.sharedVia': 'Shared via',
'shared.confirmed': 'Confirmed',
'shared.pending': 'Pending',
'share.permBudget': 'Budget',
'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',
@@ -156,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.',
@@ -177,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',
@@ -219,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…',
@@ -245,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.',
@@ -271,6 +377,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'Users', 'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories', 'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup', 'admin.tabs.backup': 'Backup',
'admin.tabs.audit': 'Audit log',
'admin.stats.users': 'Users', 'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips', 'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places', 'admin.stats.places': 'Places',
@@ -320,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',
@@ -374,8 +483,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.memories.name': 'Memories',
'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
'admin.addons.catalog.packing.name': 'Packing', 'admin.addons.catalog.packing.name': 'Packing',
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip', 'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
'admin.addons.catalog.budget.name': 'Budget', 'admin.addons.catalog.budget.name': 'Budget',
@@ -388,14 +495,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats', 'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.name': 'Collab',
'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.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',
@@ -412,7 +525,33 @@ 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.empty': 'No audit entries yet.',
'admin.audit.refresh': 'Refresh',
'admin.audit.loadMore': 'Load more',
'admin.audit.showing': '{count} loaded · {total} total',
'admin.audit.col.time': 'Time',
'admin.audit.col.user': 'User',
'admin.audit.col.action': 'Action',
'admin.audit.col.resource': 'Resource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Release History', 'admin.github.title': 'Release History',
'admin.github.subtitle': 'Latest updates from {repo}', 'admin.github.subtitle': 'Latest updates from {repo}',
'admin.github.latest': 'Latest', 'admin.github.latest': 'Latest',
@@ -475,7 +614,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'vacay.remaining': 'Left', 'vacay.remaining': 'Left',
'vacay.carriedOver': 'from {year}', 'vacay.carriedOver': 'from {year}',
'vacay.blockWeekends': 'Block Weekends', 'vacay.blockWeekends': 'Block Weekends',
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays', 'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
'vacay.weekendDays': 'Weekend days',
'vacay.mon': 'Mon',
'vacay.tue': 'Tue',
'vacay.wed': 'Wed',
'vacay.thu': 'Thu',
'vacay.fri': 'Fri',
'vacay.sat': 'Sat',
'vacay.sun': 'Sun',
'vacay.publicHolidays': 'Public Holidays', 'vacay.publicHolidays': 'Public Holidays',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.selectCountry': 'Select country', 'vacay.selectCountry': 'Select country',
@@ -534,12 +681,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'atlas.markVisited': 'Mark as visited', 'atlas.markVisited': 'Mark as visited',
'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.searchCountry': 'Search a country...',
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
'atlas.month': 'Month',
'atlas.year': 'Year',
'atlas.addToBucketHint': 'Save as a place you want to visit', 'atlas.addToBucketHint': 'Save as a place you want to visit',
'atlas.bucketWhen': 'When do you plan to visit?', 'atlas.bucketWhen': 'When do you plan to visit?',
'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',
@@ -552,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',
@@ -582,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',
@@ -597,6 +748,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Day Plan Sidebar // Day Plan Sidebar
'dayplan.emptyDay': 'No places planned for this day', 'dayplan.emptyDay': 'No places planned for this day',
'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
'dayplan.confirmRemoveTimeTitle': 'Remove time?',
'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
'dayplan.confirmRemoveTimeAction': 'Remove time & move',
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
'dayplan.addNote': 'Add Note', 'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note', 'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note', 'dayplan.noteAdd': 'Add Note',
@@ -622,11 +779,22 @@ 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': 'GPX',
'places.gpxImported': '{count} places imported from GPX',
'places.urlResolved': 'Place imported from URL',
'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',
'places.search': 'Search places...', 'places.search': 'Search places...',
'places.allCategories': 'All Categories', 'places.allCategories': 'All Categories',
'places.categoriesSelected': 'categories',
'places.clearFilter': 'Clear filter',
'places.count': '{count} places', 'places.count': '{count} places',
'places.countSingular': '1 place', 'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned', 'places.allPlanned': 'All places are planned',
@@ -675,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',
@@ -725,6 +894,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.tour': 'Tour', 'reservations.type.tour': 'Tour',
'reservations.type.other': 'Other', 'reservations.type.other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?', 'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
'reservations.confirm.deleteTitle': 'Delete booking?',
'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
'reservations.toast.updated': 'Reservation updated', 'reservations.toast.updated': 'Reservation updated',
'reservations.toast.removed': 'Reservation deleted', 'reservations.toast.removed': 'Reservation deleted',
'reservations.toast.fileUploaded': 'File uploaded', 'reservations.toast.fileUploaded': 'File uploaded',
@@ -744,6 +915,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.pendingSave': 'will be saved…', 'reservations.pendingSave': 'will be saved…',
'reservations.uploading': 'Uploading...', 'reservations.uploading': 'Uploading...',
'reservations.attachFile': 'Attach file', 'reservations.attachFile': 'Attach file',
'reservations.linkExisting': 'Link existing file',
'reservations.toast.saveError': 'Failed to save', 'reservations.toast.saveError': 'Failed to save',
'reservations.toast.updateError': 'Failed to update', 'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete', 'reservations.toast.deleteError': 'Failed to delete',
@@ -754,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...',
@@ -768,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',
@@ -781,6 +955,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'budget.paid': 'Paid', 'budget.paid': 'Paid',
'budget.open': 'Open', 'budget.open': 'Open',
'budget.noMembers': 'No members assigned', 'budget.noMembers': 'No members assigned',
'budget.settlement': 'Settlement',
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
'budget.netBalances': 'Net Balances',
// Files // Files
'files.title': 'Files', 'files.title': 'Files',
@@ -834,6 +1011,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Packing // Packing
'packing.title': 'Packing List', 'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty', 'packing.empty': 'Packing list is empty',
'packing.import': 'Import',
'packing.importTitle': 'Import Packing List',
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
'packing.importCsv': 'Load CSV/TXT',
'packing.importAction': 'Import {count}',
'packing.importSuccess': '{count} items imported',
'packing.importError': 'Import failed',
'packing.importEmpty': 'No items to import',
'packing.progress': '{packed} of {total} packed ({percent}%)', 'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked', 'packing.clearChecked': 'Remove {count} checked',
'packing.clearCheckedShort': 'Remove {count}', 'packing.clearCheckedShort': 'Remove {count}',
@@ -985,7 +1171,27 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'Enable auto-backup', 'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule', 'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval', 'backup.auto.interval': 'Interval',
'backup.auto.hour': 'Run at hour',
'backup.auto.hourHint': 'Server local time ({format} format)',
'backup.auto.dayOfWeek': 'Day of week',
'backup.auto.dayOfMonth': 'Day of month',
'backup.auto.dayOfMonthHint': 'Limited to 128 for compatibility with all months',
'backup.auto.scheduleSummary': 'Schedule',
'backup.auto.summaryDaily': 'Every day at {hour}:00',
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
'backup.auto.copyEnv': 'Copy Docker env vars',
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
'backup.auto.keepLabel': 'Delete old backups after', 'backup.auto.keepLabel': 'Delete old backups after',
'backup.dow.sunday': 'Sun',
'backup.dow.monday': 'Mon',
'backup.dow.tuesday': 'Tue',
'backup.dow.wednesday': 'Wed',
'backup.dow.thursday': 'Thu',
'backup.dow.friday': 'Fri',
'backup.dow.saturday': 'Sat',
'backup.interval.hourly': 'Hourly', 'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily', 'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly', 'backup.interval.weekly': 'Weekly',
@@ -1112,6 +1318,52 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'day.editAccommodation': 'Edit accommodation', 'day.editAccommodation': 'Edit accommodation',
'day.reservations': 'Reservations', 'day.reservations': 'Reservations',
// Photos / Immich
'memories.title': 'Photos',
'memories.notConnected': 'Immich not connected',
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
'memories.noDates': 'Add dates to your trip to load photos.',
'memories.noPhotos': 'No photos found',
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
'memories.photosFound': 'photos',
'memories.fromOthers': 'from others',
'memories.sharePhotos': 'Share photos',
'memories.sharing': 'Sharing',
'memories.reviewTitle': 'Review your photos',
'memories.reviewHint': 'Click photos to exclude them from sharing.',
'memories.shareCount': 'Share {count} photos',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API Key',
'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected',
'memories.disconnected': 'Not connected',
'memories.connectionSuccess': 'Connected to Immich',
'memories.connectionError': 'Could not connect to Immich',
'memories.saved': 'Immich settings saved',
'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.selectHint': 'Tap photos to select them.',
'memories.selected': 'selected',
'memories.addSelected': 'Add {count} photos',
'memories.alreadyAdded': 'Added',
'memories.private': 'Private',
'memories.stopSharing': 'Stop sharing',
'memories.oldest': 'Oldest first',
'memories.newest': 'Newest first',
'memories.allLocations': 'All locations',
'memories.tripDates': 'Trip dates',
'memories.allPhotos': 'All photos',
'memories.confirmShareTitle': 'Share with trip members?',
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
'memories.confirmShareButton': 'Share photos',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notes', 'collab.tabs.notes': 'Notes',
@@ -1130,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',
@@ -1180,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
+309 -4
View File
@@ -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…',
@@ -140,8 +149,94 @@ const es: Record<string, string> = {
'settings.temperature': 'Unidad de temperatura', 'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora', 'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de ruta', 'settings.routeCalculation': 'Cálculo de ruta',
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
'settings.notifications': 'Notificaciones',
'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
'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.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
'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.testFailed': 'Error al enviar correo de prueba',
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
'share.linkTitle': 'Enlace público',
'share.linkHint': 'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
'share.createLink': 'Crear enlace',
'share.deleteLink': 'Eliminar enlace',
'share.createError': 'No se pudo crear el enlace',
'common.copy': 'Copiar',
'common.copied': 'Copiado',
'share.permMap': 'Mapa y plan',
'share.permBookings': 'Reservas',
'share.permPacking': 'Equipaje',
'shared.expired': 'Enlace expirado o inválido',
'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
'shared.readOnly': 'Vista de solo lectura',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Reservas',
'shared.tabPacking': 'Equipaje',
'shared.tabBudget': 'Presupuesto',
'shared.tabChat': 'Chat',
'shared.days': 'días',
'shared.places': 'lugares',
'shared.other': 'Otro',
'shared.totalBudget': 'Presupuesto total',
'shared.messages': 'mensajes',
'shared.sharedVia': 'Compartido vía',
'shared.confirmed': 'Confirmado',
'shared.pending': 'Pendiente',
'share.permBudget': 'Presupuesto',
'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',
@@ -149,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',
@@ -167,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',
@@ -218,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…',
@@ -243,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.',
@@ -269,6 +375,7 @@ const es: Record<string, string> = {
'admin.tabs.users': 'Usuarios', 'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías', 'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad', 'admin.tabs.backup': 'Copia de seguridad',
'admin.tabs.audit': 'Registro de auditoría',
'admin.stats.users': 'Usuarios', 'admin.stats.users': 'Usuarios',
'admin.stats.trips': 'Viajes', 'admin.stats.trips': 'Viajes',
'admin.stats.places': 'Lugares', 'admin.stats.places': 'Lugares',
@@ -318,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',
@@ -375,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',
@@ -391,8 +502,37 @@ 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',
'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).',
'admin.audit.empty': 'Aún no hay entradas de auditoría.',
'admin.audit.refresh': 'Actualizar',
'admin.audit.loadMore': 'Cargar más',
'admin.audit.showing': '{count} cargados · {total} en total',
'admin.audit.col.time': 'Fecha y hora',
'admin.audit.col.user': 'Usuario',
'admin.audit.col.action': 'Acción',
'admin.audit.col.resource': 'Recurso',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Detalles',
'admin.github.title': 'Historial de versiones', 'admin.github.title': 'Historial de versiones',
'admin.github.subtitle': 'Últimas novedades de {repo}', 'admin.github.subtitle': 'Últimas novedades de {repo}',
'admin.github.latest': 'Última', 'admin.github.latest': 'Última',
@@ -455,6 +595,14 @@ const es: Record<string, string> = {
'vacay.carriedOver': 'de {year}', 'vacay.carriedOver': 'de {year}',
'vacay.blockWeekends': 'Bloquear fines de semana', 'vacay.blockWeekends': 'Bloquear fines de semana',
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos', 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
'vacay.weekendDays': 'Días de fin de semana',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mié',
'vacay.thu': 'Jue',
'vacay.fri': 'Vie',
'vacay.sat': 'Sáb',
'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festivos', 'vacay.publicHolidays': 'Festivos',
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario', 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
'vacay.selectCountry': 'Seleccionar país', 'vacay.selectCountry': 'Seleccionar país',
@@ -548,6 +696,9 @@ const es: Record<string, string> = {
'atlas.markVisited': 'Marcar como visitado', 'atlas.markVisited': 'Marcar como visitado',
'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.searchCountry': 'Buscar un país...',
'atlas.month': 'Mes',
'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?',
@@ -560,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',
@@ -597,14 +749,31 @@ const es: Record<string, string> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plan diario como PDF', 'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
'dayplan.pdfError': 'No se pudo exportar el PDF', 'dayplan.pdfError': 'No se pudo exportar el PDF',
'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
'dayplan.confirmRemoveTimeBody': 'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
'dayplan.cannotDropOnTimed': 'No se pueden colocar elementos entre entradas con hora fija',
'dayplan.cannotBreakChronology': 'Esto rompería el orden cronológico de los elementos y reservas programados',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Añadir lugar/actividad', 'places.addPlace': 'Añadir lugar/actividad',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados desde 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.assignToDay': '¿A qué día añadirlo?', 'places.assignToDay': '¿A qué día añadirlo?',
'places.all': 'Todo', 'places.all': 'Todo',
'places.unplanned': 'Sin planificar', 'places.unplanned': 'Sin planificar',
'places.search': 'Buscar lugares...', 'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas las categorías', 'places.allCategories': 'Todas las categorías',
'places.categoriesSelected': 'categorías',
'places.clearFilter': 'Borrar filtro',
'places.count': '{count} lugares', 'places.count': '{count} lugares',
'places.countSingular': '1 lugar', 'places.countSingular': '1 lugar',
'places.allPlanned': 'Todos los lugares están planificados', 'places.allPlanned': 'Todos los lugares están planificados',
@@ -654,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',
@@ -687,6 +857,8 @@ const es: Record<string, string> = {
'reservations.type.tour': 'Tour', 'reservations.type.tour': 'Tour',
'reservations.type.other': 'Otro', 'reservations.type.other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?', 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
'reservations.toast.updated': 'Reserva actualizada', 'reservations.toast.updated': 'Reserva actualizada',
'reservations.toast.removed': 'Reserva eliminada', 'reservations.toast.removed': 'Reserva eliminada',
'reservations.toast.fileUploaded': 'Archivo subido', 'reservations.toast.fileUploaded': 'Archivo subido',
@@ -706,6 +878,7 @@ const es: Record<string, string> = {
'reservations.pendingSave': 'se guardará…', 'reservations.pendingSave': 'se guardará…',
'reservations.uploading': 'Subiendo...', 'reservations.uploading': 'Subiendo...',
'reservations.attachFile': 'Adjuntar archivo', 'reservations.attachFile': 'Adjuntar archivo',
'reservations.linkExisting': 'Vincular archivo existente',
'reservations.toast.saveError': 'No se pudo guardar', 'reservations.toast.saveError': 'No se pudo guardar',
'reservations.toast.updateError': 'No se pudo actualizar', 'reservations.toast.updateError': 'No se pudo actualizar',
'reservations.toast.deleteError': 'No se pudo eliminar', 'reservations.toast.deleteError': 'No se pudo eliminar',
@@ -716,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...',
@@ -730,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',
@@ -743,6 +918,9 @@ const es: Record<string, string> = {
'budget.paid': 'Pagado', 'budget.paid': 'Pagado',
'budget.open': 'Abrir', 'budget.open': 'Abrir',
'budget.noMembers': 'No hay miembros asignados', 'budget.noMembers': 'No hay miembros asignados',
'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
// Files // Files
'files.title': 'Archivos', 'files.title': 'Archivos',
@@ -774,6 +952,15 @@ const es: Record<string, string> = {
// Packing // Packing
'packing.title': 'Lista de equipaje', 'packing.title': 'Lista de equipaje',
'packing.empty': 'La lista de equipaje está vacía', 'packing.empty': 'La lista de equipaje está vacía',
'packing.import': 'Importar',
'packing.importTitle': 'Importar lista de equipaje',
'packing.importHint': 'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
'packing.importPlaceholder': 'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
'packing.importCsv': 'Cargar CSV/TXT',
'packing.importAction': 'Importar {count}',
'packing.importSuccess': '{count} elementos importados',
'packing.importError': 'Error al importar',
'packing.importEmpty': 'Sin elementos para importar',
'packing.progress': '{packed} de {total} preparados ({percent}%)', 'packing.progress': '{packed} de {total} preparados ({percent}%)',
'packing.clearChecked': 'Eliminar {count} marcados', 'packing.clearChecked': 'Eliminar {count} marcados',
'packing.clearCheckedShort': 'Eliminar {count}', 'packing.clearCheckedShort': 'Eliminar {count}',
@@ -925,7 +1112,27 @@ const es: Record<string, string> = {
'backup.auto.enable': 'Activar copia automática', 'backup.auto.enable': 'Activar copia automática',
'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida', 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida',
'backup.auto.interval': 'Intervalo', 'backup.auto.interval': 'Intervalo',
'backup.auto.hour': 'Ejecutar a la hora',
'backup.auto.hourHint': 'Hora local del servidor (formato {format})',
'backup.auto.dayOfWeek': 'Día de la semana',
'backup.auto.dayOfMonth': 'Día del mes',
'backup.auto.dayOfMonthHint': 'Limitado a 128 para compatibilidad con todos los meses',
'backup.auto.scheduleSummary': 'Programación',
'backup.auto.summaryDaily': 'Todos los días a las {hour}:00',
'backup.auto.summaryWeekly': 'Cada {day} a las {hour}:00',
'backup.auto.summaryMonthly': 'El día {day} de cada mes a las {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'La copia automática está configurada mediante variables de entorno Docker. Para cambiar estos ajustes, actualiza tu docker-compose.yml y reinicia el contenedor.',
'backup.auto.copyEnv': 'Copiar variables de entorno Docker',
'backup.auto.envCopied': 'Variables de entorno Docker copiadas al portapapeles',
'backup.auto.keepLabel': 'Eliminar copias antiguas después de', 'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
'backup.dow.sunday': 'Dom',
'backup.dow.monday': 'Lun',
'backup.dow.tuesday': 'Mar',
'backup.dow.wednesday': 'Mié',
'backup.dow.thursday': 'Jue',
'backup.dow.friday': 'Vie',
'backup.dow.saturday': 'Sáb',
'backup.interval.hourly': 'Cada hora', 'backup.interval.hourly': 'Cada hora',
'backup.interval.daily': 'Diaria', 'backup.interval.daily': 'Diaria',
'backup.interval.weekly': 'Semanal', 'backup.interval.weekly': 'Semanal',
@@ -945,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',
@@ -1065,6 +1274,52 @@ const es: Record<string, string> = {
'day.editAccommodation': 'Editar alojamiento', 'day.editAccommodation': 'Editar alojamiento',
'day.reservations': 'Reservas', 'day.reservations': 'Reservas',
// Memories / Immich
'memories.title': 'Fotos',
'memories.notConnected': 'Immich no conectado',
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
'memories.noPhotos': 'No se encontraron fotos',
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
'memories.photosFound': 'fotos',
'memories.fromOthers': 'de otros',
'memories.sharePhotos': 'Compartir fotos',
'memories.sharing': 'Compartiendo',
'memories.reviewTitle': 'Revisar tus fotos',
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
'memories.shareCount': 'Compartir {count} fotos',
'memories.immichUrl': 'URL del servidor Immich',
'memories.immichApiKey': 'Clave API',
'memories.testConnection': 'Probar conexión',
'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado',
'memories.connectionSuccess': 'Conectado a Immich',
'memories.connectionError': 'No se pudo conectar a Immich',
'memories.saved': 'Configuración de Immich guardada',
'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones',
'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.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)',
'memories.addSelected': 'Añadir {count} fotos',
'memories.alreadyAdded': 'Añadido',
'memories.private': 'Privado',
'memories.stopSharing': 'Dejar de compartir',
'memories.tripDates': 'Fechas del viaje',
'memories.allPhotos': 'Todas las fotos',
'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
'memories.confirmShareHint': '{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
'memories.confirmShareButton': 'Compartir fotos',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Mensajes', 'collab.tabs.chat': 'Mensajes',
'collab.tabs.notes': 'Notas', 'collab.tabs.notes': 'Notas',
@@ -1083,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',
@@ -1184,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
+377 -72
View File
@@ -5,13 +5,14 @@ const fr: Record<string, string> = {
'common.delete': 'Supprimer', 'common.delete': 'Supprimer',
'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',
'common.close': 'Fermer', 'common.close': 'Fermer',
'common.open': 'Ouvrir', 'common.open': 'Ouvrir',
'common.upload': 'Téléverser', 'common.upload': 'Importer',
'common.search': 'Rechercher', 'common.search': 'Rechercher',
'common.confirm': 'Confirmer', 'common.confirm': 'Confirmer',
'common.ok': 'OK', 'common.ok': 'OK',
@@ -24,10 +25,18 @@ const fr: Record<string, string> = {
'common.name': 'Nom', 'common.name': 'Nom',
'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': 'Téléversement…', 'common.uploading': 'Import en cours…',
'common.backToPlanning': 'Retour à la planification', 'common.backToPlanning': 'Retour à la planification',
'common.reset': 'Réinitialiser', 'common.reset': 'Réinitialiser',
@@ -44,7 +53,7 @@ const fr: Record<string, string> = {
// Dashboard // Dashboard
'dashboard.title': 'Mes voyages', 'dashboard.title': 'Mes voyages',
'dashboard.subtitle.loading': 'Chargement des voyages...', 'dashboard.subtitle.loading': 'Chargement des voyages',
'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)', 'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)',
'dashboard.subtitle.empty': 'Commencez votre premier voyage', 'dashboard.subtitle.empty': 'Commencez votre premier voyage',
'dashboard.subtitle.activeOne': '{count} voyage actif', 'dashboard.subtitle.activeOne': '{count} voyage actif',
@@ -54,8 +63,8 @@ const fr: Record<string, string> = {
'dashboard.gridView': 'Vue en grille', 'dashboard.gridView': 'Vue en grille',
'dashboard.listView': 'Vue en liste', 'dashboard.listView': 'Vue en liste',
'dashboard.currency': 'Devise', 'dashboard.currency': 'Devise',
'dashboard.timezone': 'Fuseaux horaires', 'dashboard.timezone': 'Fuseau horaire',
'dashboard.localTime': 'Local', 'dashboard.localTime': 'Heure locale',
'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé', 'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)', 'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York', 'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
@@ -105,7 +114,7 @@ const fr: Record<string, string> = {
'dashboard.addMembers': 'Compagnons de voyage', 'dashboard.addMembers': 'Compagnons de voyage',
'dashboard.addMember': 'Ajouter un membre', 'dashboard.addMember': 'Ajouter un membre',
'dashboard.coverSaved': 'Image de couverture enregistrée', 'dashboard.coverSaved': 'Image de couverture enregistrée',
'dashboard.coverUploadError': 'Échec du téléversement', 'dashboard.coverUploadError': 'Échec de l\'import',
'dashboard.coverRemoveError': 'Échec de la suppression', 'dashboard.coverRemoveError': 'Échec de la suppression',
'dashboard.titleRequired': 'Le titre est obligatoire', 'dashboard.titleRequired': 'Le titre est obligatoire',
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début', 'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
@@ -115,7 +124,7 @@ const fr: Record<string, string> = {
'settings.subtitle': 'Configurez vos paramètres personnels', 'settings.subtitle': 'Configurez vos paramètres personnels',
'settings.map': 'Carte', 'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte', 'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...', 'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle',
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)', 'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte', 'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
@@ -127,7 +136,7 @@ const fr: Record<string, string> = {
'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com', 'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com',
'settings.weatherKey': 'Clé API OpenWeatherMap', 'settings.weatherKey': 'Clé API OpenWeatherMap',
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api', 'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
'settings.keyPlaceholder': 'Saisir la clé...', 'settings.keyPlaceholder': 'Saisir la clé',
'settings.configured': 'Configuré', 'settings.configured': 'Configuré',
'settings.saveKeys': 'Enregistrer les clés', 'settings.saveKeys': 'Enregistrer les clés',
'settings.display': 'Affichage', 'settings.display': 'Affichage',
@@ -139,8 +148,94 @@ const fr: Record<string, string> = {
'settings.temperature': 'Unité de température', 'settings.temperature': 'Unité de température',
'settings.timeFormat': 'Format de l\'heure', 'settings.timeFormat': 'Format de l\'heure',
'settings.routeCalculation': 'Calcul d\'itinéraire', 'settings.routeCalculation': 'Calcul d\'itinéraire',
'settings.blurBookingCodes': 'Masquer les codes de réservation',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
'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.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
'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.testFailed': 'Échec de l\'e-mail de test',
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
'share.linkTitle': 'Lien public',
'share.linkHint': 'Créez un lien que n\'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule — aucune modification possible.',
'share.createLink': 'Créer un lien',
'share.deleteLink': 'Supprimer le lien',
'share.createError': 'Impossible de créer le lien',
'common.copy': 'Copier',
'common.copied': 'Copié',
'share.permMap': 'Carte et plan',
'share.permBookings': 'Réservations',
'share.permPacking': 'Bagages',
'shared.expired': 'Lien expiré ou invalide',
'shared.expiredHint': 'Ce lien de partage n\'est plus actif.',
'shared.readOnly': 'Vue en lecture seule',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Réservations',
'shared.tabPacking': 'Bagages',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'jours',
'shared.places': 'lieux',
'shared.other': 'Autre',
'shared.totalBudget': 'Budget total',
'shared.messages': 'messages',
'shared.sharedVia': 'Partagé via',
'shared.confirmed': 'Confirmé',
'shared.pending': 'En attente',
'share.permBudget': 'Budget',
'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',
@@ -148,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',
@@ -156,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 ?',
@@ -168,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',
@@ -186,15 +290,15 @@ const fr: Record<string, string> = {
'settings.toast.keysSaved': 'Clés API enregistrées', 'settings.toast.keysSaved': 'Clés API enregistrées',
'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés', 'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés',
'settings.toast.profileSaved': 'Profil enregistré', 'settings.toast.profileSaved': 'Profil enregistré',
'settings.uploadAvatar': 'Téléverser une photo de profil', 'settings.uploadAvatar': 'Importer une photo de profil',
'settings.removeAvatar': 'Supprimer la photo de profil', 'settings.removeAvatar': 'Supprimer la photo de profil',
'settings.avatarUploaded': 'Photo de profil mise à jour', 'settings.avatarUploaded': 'Photo de profil mise à jour',
'settings.avatarRemoved': 'Photo de profil supprimée', 'settings.avatarRemoved': 'Photo de profil supprimée',
'settings.avatarError': 'Échec du téléversement', 'settings.avatarError': 'Échec de l\'import',
// Login // Login
'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.', 'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
'login.tagline': 'Vos voyages.\nVotre plan.', 'login.tagline': 'Vos voyages.\nVotre organisation.',
'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.', 'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
'login.features.maps': 'Cartes interactives', 'login.features.maps': 'Cartes interactives',
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement', 'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
@@ -209,7 +313,7 @@ const fr: Record<string, string> = {
'login.features.bookings': 'Réservations', 'login.features.bookings': 'Réservations',
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus', 'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
'login.features.files': 'Documents', 'login.features.files': 'Documents',
'login.features.filesDesc': 'Téléversez et gérez vos documents', 'login.features.filesDesc': 'Importez et gérez vos documents',
'login.features.routes': 'Itinéraires intelligents', 'login.features.routes': 'Itinéraires intelligents',
'login.features.routesDesc': 'Optimisation automatique et export Google Maps', 'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres', 'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
@@ -219,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…',
@@ -245,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.',
@@ -260,7 +366,7 @@ const fr: Record<string, string> = {
'register.minChars': 'Min. 6 caractères', 'register.minChars': 'Min. 6 caractères',
'register.confirmPassword': 'Confirmer le mot de passe', 'register.confirmPassword': 'Confirmer le mot de passe',
'register.repeatPassword': 'Répéter le mot de passe', 'register.repeatPassword': 'Répéter le mot de passe',
'register.registering': 'Inscription en cours...', 'register.registering': 'Inscription en cours',
'register.register': 'S\'inscrire', 'register.register': 'S\'inscrire',
'register.hasAccount': 'Vous avez déjà un compte ?', 'register.hasAccount': 'Vous avez déjà un compte ?',
'register.signIn': 'Se connecter', 'register.signIn': 'Se connecter',
@@ -320,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',
@@ -343,7 +451,7 @@ const fr: Record<string, string> = {
// File Types // File Types
'admin.fileTypes': 'Types de fichiers autorisés', 'admin.fileTypes': 'Types de fichiers autorisés',
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.', 'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.',
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.', 'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés', 'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
@@ -373,19 +481,21 @@ 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',
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage', 'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents', 'admin.addons.catalog.documents.name': 'Documents',
'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage', 'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage',
'admin.addons.catalog.vacay.name': 'Vacay', 'admin.addons.catalog.vacay.name': 'Vacances',
'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier', 'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier',
'admin.addons.catalog.atlas.name': 'Atlas', 'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage', 'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.name': 'Collaboration',
'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage', 'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage',
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ', 'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
'admin.addons.subtitleAfter': '.', 'admin.addons.subtitleAfter': '.',
@@ -393,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',
@@ -408,7 +520,37 @@ const fr: Record<string, string> = {
'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours', 'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours',
'admin.weather.requests': '10 000 requêtes / jour', 'admin.weather.requests': '10 000 requêtes / jour',
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise', 'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.', 'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
'admin.tabs.audit': 'Journal d\'audit',
'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
'admin.audit.empty': 'Aucune entrée d\'audit.',
'admin.audit.refresh': 'Actualiser',
'admin.audit.loadMore': 'Charger plus',
'admin.audit.showing': '{count} chargées · {total} au total',
'admin.audit.col.time': 'Heure',
'admin.audit.col.user': 'Utilisateur',
'admin.audit.col.action': 'Action',
'admin.audit.col.resource': 'Ressource',
'admin.audit.col.ip': 'IP',
'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',
@@ -419,8 +561,8 @@ const fr: Record<string, string> = {
'admin.github.showDetails': 'Afficher les détails', 'admin.github.showDetails': 'Afficher les détails',
'admin.github.hideDetails': 'Masquer les détails', 'admin.github.hideDetails': 'Masquer les détails',
'admin.github.loadMore': 'Charger plus', 'admin.github.loadMore': 'Charger plus',
'admin.github.loading': 'Chargement...', 'admin.github.loading': 'Chargement',
'admin.github.support': 'Aide à continuer le développement de TREK', 'admin.github.support': 'Aidez à poursuivre le développement de TREK',
'admin.github.error': 'Impossible de charger les versions', 'admin.github.error': 'Impossible de charger les versions',
'admin.github.by': 'par', 'admin.github.by': 'par',
@@ -430,7 +572,7 @@ const fr: Record<string, string> = {
'admin.update.install': 'Installer la mise à jour', 'admin.update.install': 'Installer la mise à jour',
'admin.update.confirmTitle': 'Installer la mise à jour ?', 'admin.update.confirmTitle': 'Installer la mise à jour ?',
'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.', 'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.',
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.', 'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.',
'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.', 'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
'admin.update.confirm': 'Mettre à jour maintenant', 'admin.update.confirm': 'Mettre à jour maintenant',
'admin.update.installing': 'Mise à jour…', 'admin.update.installing': 'Mise à jour…',
@@ -443,7 +585,7 @@ const fr: Record<string, string> = {
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.', 'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
// Vacay addon // Vacay addon
'vacay.subtitle': 'Planifiez et gérez vos jours de congé', 'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres', 'vacay.settings': 'Paramètres',
'vacay.year': 'Année', 'vacay.year': 'Année',
'vacay.addYear': 'Ajouter une année', 'vacay.addYear': 'Ajouter une année',
@@ -473,6 +615,14 @@ const fr: Record<string, string> = {
'vacay.used': 'Utilisés', 'vacay.used': 'Utilisés',
'vacay.remaining': 'Restants', 'vacay.remaining': 'Restants',
'vacay.carriedOver': 'de {year}', 'vacay.carriedOver': 'de {year}',
'vacay.weekendDays': 'Jours de week-end',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mer',
'vacay.thu': 'Jeu',
'vacay.fri': 'Ven',
'vacay.sat': 'Sam',
'vacay.sun': 'Dim',
'vacay.blockWeekends': 'Bloquer les week-ends', 'vacay.blockWeekends': 'Bloquer les week-ends',
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches', 'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
'vacay.publicHolidays': 'Jours fériés', 'vacay.publicHolidays': 'Jours fériés',
@@ -490,11 +640,11 @@ const fr: Record<string, string> = {
'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK', 'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK',
'vacay.shareSuccess': 'Plan partagé avec succès', 'vacay.shareSuccess': 'Plan partagé avec succès',
'vacay.shareError': 'Impossible de partager le plan', 'vacay.shareError': 'Impossible de partager le plan',
'vacay.dissolve': 'Dissoudre la fusion', 'vacay.dissolve': 'Séparer les calendriers',
'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.', 'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.',
'vacay.dissolveAction': 'Dissoudre', 'vacay.dissolveAction': 'Dissoudre',
'vacay.dissolved': 'Calendrier séparé', 'vacay.dissolved': 'Calendrier séparé',
'vacay.fusedWith': 'Fusionné avec', 'vacay.fusedWith': 'Partagé avec',
'vacay.you': 'vous', 'vacay.you': 'vous',
'vacay.noData': 'Aucune donnée', 'vacay.noData': 'Aucune donnée',
'vacay.changeColor': 'Changer la couleur', 'vacay.changeColor': 'Changer la couleur',
@@ -569,6 +719,9 @@ const fr: Record<string, string> = {
'atlas.markVisited': 'Marquer comme visité', 'atlas.markVisited': 'Marquer comme visité',
'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.searchCountry': 'Rechercher un pays…',
'atlas.month': 'Mois',
'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 ?',
@@ -580,14 +733,15 @@ const fr: Record<string, string> = {
'trip.tabs.packingShort': 'Bagages', 'trip.tabs.packingShort': 'Bagages',
'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',
'trip.toast.placeAdded': 'Lieu ajouté', 'trip.toast.placeAdded': 'Lieu ajouté',
'trip.toast.placeDeleted': 'Lieu supprimé', 'trip.toast.placeDeleted': 'Lieu supprimé',
'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour', 'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour',
'trip.toast.assignedToDay': 'Lieu assigné au jour', 'trip.toast.assignedToDay': 'Lieu attribué au planning',
'trip.toast.reorderError': 'Échec de la réorganisation', 'trip.toast.reorderError': 'Échec de la réorganisation',
'trip.toast.reservationUpdated': 'Réservation mise à jour', 'trip.toast.reservationUpdated': 'Réservation mise à jour',
'trip.toast.reservationAdded': 'Réservation ajoutée', 'trip.toast.reservationAdded': 'Réservation ajoutée',
@@ -605,7 +759,7 @@ const fr: Record<string, string> = {
'dayplan.totalCost': 'Coût total', 'dayplan.totalCost': 'Coût total',
'dayplan.days': 'Jours', 'dayplan.days': 'Jours',
'dayplan.dayN': 'Jour {n}', 'dayplan.dayN': 'Jour {n}',
'dayplan.calculating': 'Calcul en cours...', 'dayplan.calculating': 'Calcul en cours',
'dayplan.route': 'Itinéraire', 'dayplan.route': 'Itinéraire',
'dayplan.optimize': 'Optimiser', 'dayplan.optimize': 'Optimiser',
'dayplan.optimized': 'Itinéraire optimisé', 'dayplan.optimized': 'Itinéraire optimisé',
@@ -618,14 +772,31 @@ const fr: Record<string, string> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF', 'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
'dayplan.pdfError': 'Échec de l\'export PDF', 'dayplan.pdfError': 'Échec de l\'export PDF',
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
'dayplan.confirmRemoveTimeTitle': 'Supprimer l\'heure ?',
'dayplan.confirmRemoveTimeBody': 'Ce lieu a une heure fixe ({time}). Le déplacer supprimera l\'heure et permettra un tri libre.',
'dayplan.confirmRemoveTimeAction': 'Supprimer l\'heure et déplacer',
'dayplan.cannotDropOnTimed': 'Les éléments ne peuvent pas être placés entre des entrées à heure fixe',
'dayplan.cannotBreakChronology': 'Cela briserait l\'ordre chronologique des éléments et réservations planifiés',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Ajouter un lieu/activité', 'places.addPlace': 'Ajouter un lieu/activité',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lieux importés depuis GPX',
'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.assignToDay': 'Ajouter à quel jour ?', 'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous', 'places.all': 'Tous',
'places.unplanned': 'Non planifiés', 'places.unplanned': 'Non planifiés',
'places.search': 'Rechercher des lieux...', 'places.search': 'Rechercher des lieux',
'places.allCategories': 'Toutes les catégories', 'places.allCategories': 'Toutes les catégories',
'places.categoriesSelected': 'catégories',
'places.clearFilter': 'Effacer le filtre',
'places.count': '{count} lieux', 'places.count': '{count} lieux',
'places.countSingular': '1 lieu', 'places.countSingular': '1 lieu',
'places.allPlanned': 'Tous les lieux sont planifiés', 'places.allPlanned': 'Tous les lieux sont planifiés',
@@ -634,7 +805,7 @@ const fr: Record<string, string> = {
'places.formName': 'Nom', 'places.formName': 'Nom',
'places.formNamePlaceholder': 'ex. Tour Eiffel', 'places.formNamePlaceholder': 'ex. Tour Eiffel',
'places.formDescription': 'Description', 'places.formDescription': 'Description',
'places.formDescriptionPlaceholder': 'Brève description...', 'places.formDescriptionPlaceholder': 'Brève description',
'places.formAddress': 'Adresse', 'places.formAddress': 'Adresse',
'places.formAddressPlaceholder': 'Rue, ville, pays', 'places.formAddressPlaceholder': 'Rue, ville, pays',
'places.formLat': 'Latitude (ex. 48.8566)', 'places.formLat': 'Latitude (ex. 48.8566)',
@@ -648,10 +819,10 @@ const fr: Record<string, string> = {
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début', 'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
'places.timeCollision': 'Chevauchement horaire avec :', 'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web', 'places.formWebsite': 'Site web',
'places.formNotesPlaceholder': 'Notes personnelles...', 'places.formNotesPlaceholder': 'Notes personnelles',
'places.formReservation': 'Réservation', 'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...', 'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation',
'places.mapsSearchPlaceholder': 'Rechercher des lieux...', 'places.mapsSearchPlaceholder': 'Rechercher des lieux',
'places.mapsSearchError': 'La recherche de lieu a échoué.', 'places.mapsSearchError': 'La recherche de lieu a échoué.',
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.', 'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.', 'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
@@ -674,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',
@@ -696,7 +868,7 @@ const fr: Record<string, string> = {
'reservations.time': 'Heure', 'reservations.time': 'Heure',
'reservations.timeAlt': 'Heure (alternative, ex. 19h30)', 'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
'reservations.notes': 'Notes', 'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Notes supplémentaires...', 'reservations.notesPlaceholder': 'Notes supplémentaires',
'reservations.meta.airline': 'Compagnie aérienne', 'reservations.meta.airline': 'Compagnie aérienne',
'reservations.meta.flightNumber': 'N° de vol', 'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De', 'reservations.meta.from': 'De',
@@ -724,16 +896,18 @@ const fr: Record<string, string> = {
'reservations.type.tour': 'Visite', 'reservations.type.tour': 'Visite',
'reservations.type.other': 'Autre', 'reservations.type.other': 'Autre',
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?', 'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'reservations.toast.updated': 'Réservation mise à jour', 'reservations.toast.updated': 'Réservation mise à jour',
'reservations.toast.removed': 'Réservation supprimée', 'reservations.toast.removed': 'Réservation supprimée',
'reservations.toast.fileUploaded': 'Fichier téléversé', 'reservations.toast.fileUploaded': 'Fichier importé',
'reservations.toast.uploadError': 'Échec du téléversement', 'reservations.toast.uploadError': 'Échec de l\'import',
'reservations.newTitle': 'Nouvelle réservation', 'reservations.newTitle': 'Nouvelle réservation',
'reservations.bookingType': 'Type de réservation', 'reservations.bookingType': 'Type de réservation',
'reservations.titleLabel': 'Titre', 'reservations.titleLabel': 'Titre',
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...', 'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ',
'reservations.locationAddress': 'Lieu / Adresse', 'reservations.locationAddress': 'Lieu / Adresse',
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...', 'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel',
'reservations.confirmationCode': 'Code de réservation', 'reservations.confirmationCode': 'Code de réservation',
'reservations.confirmationPlaceholder': 'ex. ABC12345', 'reservations.confirmationPlaceholder': 'ex. ABC12345',
'reservations.day': 'Jour', 'reservations.day': 'Jour',
@@ -741,21 +915,23 @@ const fr: Record<string, string> = {
'reservations.place': 'Lieu', 'reservations.place': 'Lieu',
'reservations.noPlace': 'Aucun lieu', 'reservations.noPlace': 'Aucun lieu',
'reservations.pendingSave': 'sera enregistré…', 'reservations.pendingSave': 'sera enregistré…',
'reservations.uploading': 'Téléversement...', 'reservations.uploading': 'Importation…',
'reservations.attachFile': 'Joindre un fichier', 'reservations.attachFile': 'Joindre un fichier',
'reservations.linkExisting': 'Lier un fichier existant',
'reservations.toast.saveError': 'Échec de l\'enregistrement', 'reservations.toast.saveError': 'Échec de l\'enregistrement',
'reservations.toast.updateError': 'Échec de la mise à jour', 'reservations.toast.updateError': 'Échec de la mise à jour',
'reservations.toast.deleteError': 'Échec de la suppression', 'reservations.toast.deleteError': 'Échec de la suppression',
'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?', 'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?',
'reservations.linkAssignment': 'Lier à l\'assignation du jour', 'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...', 'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan',
'reservations.noAssignment': 'Aucun lien (autonome)', 'reservations.noAssignment': 'Aucun lien (autonome)',
// 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',
'budget.createCategory': 'Créer une catégorie', 'budget.createCategory': 'Créer une catégorie',
'budget.category': 'Catégorie', 'budget.category': 'Catégorie',
'budget.categoryName': 'Nom de la catégorie', 'budget.categoryName': 'Nom de la catégorie',
@@ -767,30 +943,34 @@ 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',
'budget.total': 'Total', 'budget.total': 'Total',
'budget.totalBudget': 'Budget total', 'budget.totalBudget': 'Budget total',
'budget.byCategory': 'Par catégorie', 'budget.byCategory': 'Par catégorie',
'budget.editTooltip': 'Cliquer pour modifier', 'budget.editTooltip': 'Cliquez pour modifier',
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?', 'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
'budget.deleteCategory': 'Supprimer la catégorie', 'budget.deleteCategory': 'Supprimer la catégorie',
'budget.perPerson': 'Par personne', 'budget.perPerson': 'Par personne',
'budget.paid': 'Payé', 'budget.paid': 'Payé',
'budget.open': 'Ouvert', 'budget.open': 'Ouvert',
'budget.noMembers': 'Aucun membre assigné', 'budget.noMembers': 'Aucun membre assigné',
'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets',
// Files // Files
'files.title': 'Fichiers', 'files.title': 'Fichiers',
'files.count': '{count} fichiers', 'files.count': '{count} fichiers',
'files.countSingular': '1 fichier', 'files.countSingular': '1 fichier',
'files.uploaded': '{count} téléversés', 'files.uploaded': '{count} importés',
'files.uploadError': 'Échec du téléversement', 'files.uploadError': 'Échec de l\'import',
'files.dropzone': 'Déposez les fichiers ici', 'files.dropzone': 'Déposez les fichiers ici',
'files.dropzoneHint': 'ou cliquez pour parcourir', 'files.dropzoneHint': 'ou cliquez pour parcourir',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
'files.uploading': 'Téléversement...', 'files.uploading': 'Importation…',
'files.filterAll': 'Tous', 'files.filterAll': 'Tous',
'files.filterPdf': 'PDF', 'files.filterPdf': 'PDF',
'files.filterImages': 'Images', 'files.filterImages': 'Images',
@@ -798,7 +978,7 @@ const fr: Record<string, string> = {
'files.filterCollab': 'Notes Collab', 'files.filterCollab': 'Notes Collab',
'files.sourceCollab': 'Depuis les notes Collab', 'files.sourceCollab': 'Depuis les notes Collab',
'files.empty': 'Aucun fichier', 'files.empty': 'Aucun fichier',
'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage', 'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage',
'files.openTab': 'Ouvrir dans un nouvel onglet', 'files.openTab': 'Ouvrir dans un nouvel onglet',
'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?', 'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
'files.toast.deleted': 'Fichier supprimé', 'files.toast.deleted': 'Fichier supprimé',
@@ -817,22 +997,31 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier', 'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu', 'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation', 'files.assignBooking': 'Réservation',
'files.unassigned': 'Non assigné', 'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien', 'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille', 'files.toast.trashed': 'Déplacé dans la corbeille',
'files.toast.restored': 'Fichier restauré', 'files.toast.restored': 'Fichier restauré',
'files.toast.trashEmptied': 'Corbeille vidée', 'files.toast.trashEmptied': 'Corbeille vidée',
'files.toast.assigned': 'Fichier assigné', 'files.toast.assigned': 'Fichier attribué',
'files.toast.assignError': 'Échec de l\'assignation', 'files.toast.assignError': 'Échec de l\'assignation',
'files.toast.restoreError': 'Échec de la restauration', 'files.toast.restoreError': 'Échec de la restauration',
'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.', 'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.',
'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.', 'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.',
'files.noteLabel': 'Note', 'files.noteLabel': 'Note',
'files.notePlaceholder': 'Ajouter une note...', 'files.notePlaceholder': 'Ajouter une note',
// Packing // Packing
'packing.title': 'Liste de bagages', 'packing.title': 'Liste de bagages',
'packing.empty': 'La liste de bagages est vide', 'packing.empty': 'La liste de bagages est vide',
'packing.import': 'Importer',
'packing.importTitle': 'Importer la liste',
'packing.importHint': 'Un élément par ligne. Catégorie et quantité optionnelles séparées par virgule, point-virgule ou tabulation : Nom, Catégorie, Quantité',
'packing.importPlaceholder': 'Brosse à dents\nCrème solaire, Hygiène\nT-Shirts, Vêtements, 5\nPasseport, Documents',
'packing.importCsv': 'Charger CSV/TXT',
'packing.importAction': 'Importer {count}',
'packing.importSuccess': '{count} éléments importés',
'packing.importError': 'Échec de l\'import',
'packing.importEmpty': 'Aucun élément à importer',
'packing.progress': '{packed} sur {total} emballés ({percent} %)', 'packing.progress': '{packed} sur {total} emballés ({percent} %)',
'packing.clearChecked': 'Supprimer {count} cochés', 'packing.clearChecked': 'Supprimer {count} cochés',
'packing.clearCheckedShort': 'Supprimer {count}', 'packing.clearCheckedShort': 'Supprimer {count}',
@@ -840,8 +1029,8 @@ const fr: Record<string, string> = {
'packing.suggestionsTitle': 'Ajouter des suggestions', 'packing.suggestionsTitle': 'Ajouter des suggestions',
'packing.allSuggested': 'Toutes les suggestions ajoutées', 'packing.allSuggested': 'Toutes les suggestions ajoutées',
'packing.allPacked': 'Tout est emballé !', 'packing.allPacked': 'Tout est emballé !',
'packing.addPlaceholder': 'Ajouter un nouvel article...', 'packing.addPlaceholder': 'Ajouter un nouvel article',
'packing.categoryPlaceholder': 'Catégorie...', 'packing.categoryPlaceholder': 'Catégorie',
'packing.filterAll': 'Tous', 'packing.filterAll': 'Tous',
'packing.filterOpen': 'À faire', 'packing.filterOpen': 'À faire',
'packing.filterDone': 'Fait', 'packing.filterDone': 'Fait',
@@ -955,10 +1144,10 @@ const fr: Record<string, string> = {
// Backup (Admin) // Backup (Admin)
'backup.title': 'Sauvegarde des données', 'backup.title': 'Sauvegarde des données',
'backup.subtitle': 'Base de données et tous les fichiers téléversés', 'backup.subtitle': 'Base de données et tous les fichiers importés',
'backup.refresh': 'Actualiser', 'backup.refresh': 'Actualiser',
'backup.upload': 'Téléverser une sauvegarde', 'backup.upload': 'Importer une sauvegarde',
'backup.uploading': 'Téléversement…', 'backup.uploading': 'Importation…',
'backup.create': 'Créer une sauvegarde', 'backup.create': 'Créer une sauvegarde',
'backup.creating': 'Création…', 'backup.creating': 'Création…',
'backup.empty': 'Aucune sauvegarde', 'backup.empty': 'Aucune sauvegarde',
@@ -966,14 +1155,14 @@ const fr: Record<string, string> = {
'backup.download': 'Télécharger', 'backup.download': 'Télécharger',
'backup.restore': 'Restaurer', 'backup.restore': 'Restaurer',
'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.', 'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.',
'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.', 'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?', 'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?',
'backup.toast.loadError': 'Impossible de charger les sauvegardes', 'backup.toast.loadError': 'Impossible de charger les sauvegardes',
'backup.toast.created': 'Sauvegarde créée avec succès', 'backup.toast.created': 'Sauvegarde créée avec succès',
'backup.toast.createError': 'Impossible de créer la sauvegarde', 'backup.toast.createError': 'Impossible de créer la sauvegarde',
'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…', 'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…',
'backup.toast.restoreError': 'Échec de la restauration', 'backup.toast.restoreError': 'Échec de la restauration',
'backup.toast.uploadError': 'Échec du téléversement', 'backup.toast.uploadError': 'Échec de l\'import',
'backup.toast.deleted': 'Sauvegarde supprimée', 'backup.toast.deleted': 'Sauvegarde supprimée',
'backup.toast.deleteError': 'Échec de la suppression', 'backup.toast.deleteError': 'Échec de la suppression',
'backup.toast.downloadError': 'Échec du téléchargement', 'backup.toast.downloadError': 'Échec du téléchargement',
@@ -984,7 +1173,27 @@ const fr: Record<string, string> = {
'backup.auto.enable': 'Activer la sauvegarde automatique', 'backup.auto.enable': 'Activer la sauvegarde automatique',
'backup.auto.enableHint': 'Les sauvegardes seront créées automatiquement selon le calendrier choisi', 'backup.auto.enableHint': 'Les sauvegardes seront créées automatiquement selon le calendrier choisi',
'backup.auto.interval': 'Intervalle', 'backup.auto.interval': 'Intervalle',
'backup.auto.hour': 'Exécuter à l\'heure',
'backup.auto.hourHint': 'Heure locale du serveur (format {format})',
'backup.auto.dayOfWeek': 'Jour de la semaine',
'backup.auto.dayOfMonth': 'Jour du mois',
'backup.auto.dayOfMonthHint': 'Limité à 128 pour la compatibilité avec tous les mois',
'backup.auto.scheduleSummary': 'Planification',
'backup.auto.summaryDaily': 'Tous les jours à {hour}h00',
'backup.auto.summaryWeekly': 'Chaque {day} à {hour}h00',
'backup.auto.summaryMonthly': 'Le {day} de chaque mois à {hour}h00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'La sauvegarde automatique est configurée via les variables d\'environnement Docker. Pour modifier ces paramètres, mettez à jour votre docker-compose.yml et redémarrez le conteneur.',
'backup.auto.copyEnv': 'Copier les variables d\'env Docker',
'backup.auto.envCopied': 'Variables d\'env Docker copiées dans le presse-papiers',
'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après', 'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après',
'backup.dow.sunday': 'Dim',
'backup.dow.monday': 'Lun',
'backup.dow.tuesday': 'Mar',
'backup.dow.wednesday': 'Mer',
'backup.dow.thursday': 'Jeu',
'backup.dow.friday': 'Ven',
'backup.dow.saturday': 'Sam',
'backup.interval.hourly': 'Toutes les heures', 'backup.interval.hourly': 'Toutes les heures',
'backup.interval.daily': 'Quotidien', 'backup.interval.daily': 'Quotidien',
'backup.interval.weekly': 'Hebdomadaire', 'backup.interval.weekly': 'Hebdomadaire',
@@ -999,15 +1208,15 @@ const fr: Record<string, string> = {
// Photos // Photos
'photos.allDays': 'Tous les jours', 'photos.allDays': 'Tous les jours',
'photos.noPhotos': 'Aucune photo', 'photos.noPhotos': 'Aucune photo',
'photos.uploadHint': 'Téléversez vos photos de voyage', 'photos.uploadHint': 'Importez vos photos de voyage',
'photos.clickToSelect': 'ou cliquez pour sélectionner', 'photos.clickToSelect': 'ou cliquez pour sélectionner',
'photos.linkPlace': 'Lier au lieu', 'photos.linkPlace': 'Lier au lieu',
'photos.noPlace': 'Aucun lieu', 'photos.noPlace': 'Aucun lieu',
'photos.uploadN': '{n} photo(s) téléversées', 'photos.uploadN': '{n} photo(s) importée(s)',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?', 'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.', 'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.', 'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
'backup.restoreConfirm': 'Oui, restaurer', 'backup.restoreConfirm': 'Oui, restaurer',
@@ -1044,8 +1253,8 @@ const fr: Record<string, string> = {
'planner.placeN': '{n} lieux', 'planner.placeN': '{n} lieux',
'planner.addNote': 'Ajouter une note', 'planner.addNote': 'Ajouter une note',
'planner.noEntries': 'Aucune entrée pour ce jour', 'planner.noEntries': 'Aucune entrée pour ce jour',
'planner.addPlace': 'Ajouter un lieu/activité', 'planner.addPlace': 'Ajouter un lieu ou une activité',
'planner.addPlaceShort': '+ Ajouter un lieu/activité', 'planner.addPlaceShort': '+ Ajouter un lieu ou une activité',
'planner.resPending': 'Réservation en attente · ', 'planner.resPending': 'Réservation en attente · ',
'planner.resConfirmed': 'Réservation confirmée · ', 'planner.resConfirmed': 'Réservation confirmée · ',
'planner.notePlaceholder': 'Note…', 'planner.notePlaceholder': 'Note…',
@@ -1075,7 +1284,7 @@ const fr: Record<string, string> = {
'planner.noDays': 'Aucun jour', 'planner.noDays': 'Aucun jour',
'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours', 'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
'planner.dayCount': '{n} jours', 'planner.dayCount': '{n} jours',
'planner.clickToUnlock': 'Cliquer pour déverrouiller', 'planner.clickToUnlock': 'Cliquez pour déverrouiller',
'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire', 'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire',
'planner.dayDetails': 'Détails du jour', 'planner.dayDetails': 'Détails du jour',
'planner.dayN': 'Jour {n}', 'planner.dayN': 'Jour {n}',
@@ -1111,8 +1320,54 @@ const fr: Record<string, string> = {
'day.editAccommodation': 'Modifier l\'hébergement', 'day.editAccommodation': 'Modifier l\'hébergement',
'day.reservations': 'Réservations', 'day.reservations': 'Réservations',
// Memories / Immich
'memories.title': 'Photos',
'memories.notConnected': 'Immich non connecté',
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
'memories.noPhotos': 'Aucune photo trouvée',
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
'memories.photosFound': 'photos',
'memories.fromOthers': 'd\'autres',
'memories.sharePhotos': 'Partager les photos',
'memories.sharing': 'Partagé',
'memories.reviewTitle': 'Vérifier vos photos',
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
'memories.shareCount': 'Partager {count} photos',
'memories.immichUrl': 'URL du serveur Immich',
'memories.immichApiKey': 'Clé API',
'memories.testConnection': 'Tester la connexion',
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté',
'memories.connectionSuccess': 'Connecté à Immich',
'memories.connectionError': 'Impossible de se connecter à Immich',
'memories.saved': 'Paramètres Immich enregistrés',
'memories.oldest': 'Plus anciennes',
'memories.newest': 'Plus récentes',
'memories.allLocations': 'Tous les lieux',
'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.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos',
'memories.alreadyAdded': 'Ajouté',
'memories.private': 'Privé',
'memories.stopSharing': 'Arrêter le partage',
'memories.tripDates': 'Dates du voyage',
'memories.allPhotos': 'Toutes les photos',
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos individuelles privées plus tard.',
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Discussion',
'collab.tabs.notes': 'Notes', 'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Sondages', 'collab.tabs.polls': 'Sondages',
'collab.whatsNext.title': 'À venir', 'collab.whatsNext.title': 'À venir',
@@ -1122,13 +1377,14 @@ const fr: Record<string, string> = {
'collab.whatsNext.until': 'à', 'collab.whatsNext.until': 'à',
'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici', 'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici',
'collab.chat.send': 'Envoyer', 'collab.chat.send': 'Envoyer',
'collab.chat.placeholder': 'Écrire un message...', 'collab.chat.placeholder': 'Écrire un message',
'collab.chat.empty': 'Commencez la conversation', 'collab.chat.empty': 'Commencez la conversation',
'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage', 'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage',
'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage', 'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage',
'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',
@@ -1139,9 +1395,9 @@ const fr: Record<string, string> = {
'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans', 'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans',
'collab.notes.all': 'Toutes', 'collab.notes.all': 'Toutes',
'collab.notes.titlePlaceholder': 'Titre de la note', 'collab.notes.titlePlaceholder': 'Titre de la note',
'collab.notes.contentPlaceholder': 'Écrivez quelque chose...', 'collab.notes.contentPlaceholder': 'Écrivez quelque chose',
'collab.notes.categoryPlaceholder': 'Catégorie', 'collab.notes.categoryPlaceholder': 'Catégorie',
'collab.notes.newCategory': 'Nouvelle catégorie...', 'collab.notes.newCategory': 'Nouvelle catégorie',
'collab.notes.category': 'Catégorie', 'collab.notes.category': 'Catégorie',
'collab.notes.noCategory': 'Sans catégorie', 'collab.notes.noCategory': 'Sans catégorie',
'collab.notes.color': 'Couleur', 'collab.notes.color': 'Couleur',
@@ -1155,7 +1411,7 @@ const fr: Record<string, string> = {
'collab.notes.categorySettings': 'Gérer les catégories', 'collab.notes.categorySettings': 'Gérer les catégories',
'collab.notes.create': 'Créer', 'collab.notes.create': 'Créer',
'collab.notes.website': 'Site web', 'collab.notes.website': 'Site web',
'collab.notes.websitePlaceholder': 'https://...', 'collab.notes.websitePlaceholder': 'https://',
'collab.notes.attachFiles': 'Joindre des fichiers', 'collab.notes.attachFiles': 'Joindre des fichiers',
'collab.notes.noCategoriesYet': 'Aucune catégorie', 'collab.notes.noCategoriesYet': 'Aucune catégorie',
'collab.notes.emptyDesc': 'Créez une note pour commencer', 'collab.notes.emptyDesc': 'Créez une note pour commencer',
@@ -1179,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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+309 -4
View File
@@ -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…',
@@ -139,8 +148,94 @@ const nl: Record<string, string> = {
'settings.temperature': 'Temperatuureenheid', 'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie', 'settings.timeFormat': 'Tijdnotatie',
'settings.routeCalculation': 'Routeberekening', 'settings.routeCalculation': 'Routeberekening',
'settings.blurBookingCodes': 'Boekingscodes vervagen',
'settings.notifications': 'Meldingen',
'settings.notifyTripInvite': 'Reisuitnodigingen',
'settings.notifyBookingChange': 'Boekingswijzigingen',
'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
'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.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
'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.testFailed': 'Test-e-mail mislukt',
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
'share.linkTitle': 'Openbare link',
'share.linkHint': 'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen — bewerken niet mogelijk.',
'share.createLink': 'Link aanmaken',
'share.deleteLink': 'Link verwijderen',
'share.createError': 'Kon link niet aanmaken',
'common.copy': 'Kopiëren',
'common.copied': 'Gekopieerd',
'share.permMap': 'Kaart en plan',
'share.permBookings': 'Boekingen',
'share.permPacking': 'Paklijst',
'shared.expired': 'Link verlopen of ongeldig',
'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.',
'shared.readOnly': 'Alleen-lezen weergave',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Boekingen',
'shared.tabPacking': 'Paklijst',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'dagen',
'shared.places': 'plaatsen',
'shared.other': 'Overig',
'shared.totalBudget': 'Totaal budget',
'shared.messages': 'berichten',
'shared.sharedVia': 'Gedeeld via',
'shared.confirmed': 'Bevestigd',
'shared.pending': 'In afwachting',
'share.permBudget': 'Budget',
'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',
@@ -148,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',
@@ -156,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?',
@@ -168,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',
@@ -219,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…',
@@ -245,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.',
@@ -271,6 +377,7 @@ const nl: Record<string, string> = {
'admin.tabs.users': 'Gebruikers', 'admin.tabs.users': 'Gebruikers',
'admin.tabs.categories': 'Categorieën', 'admin.tabs.categories': 'Categorieën',
'admin.tabs.backup': 'Back-up', 'admin.tabs.backup': 'Back-up',
'admin.tabs.audit': 'Auditlog',
'admin.stats.users': 'Gebruikers', 'admin.stats.users': 'Gebruikers',
'admin.stats.trips': 'Reizen', 'admin.stats.trips': 'Reizen',
'admin.stats.places': 'Plaatsen', 'admin.stats.places': 'Plaatsen',
@@ -320,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',
@@ -373,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',
@@ -393,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',
@@ -410,8 +523,37 @@ 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',
'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).',
'admin.audit.empty': 'Nog geen auditregistraties.',
'admin.audit.refresh': 'Vernieuwen',
'admin.audit.loadMore': 'Meer laden',
'admin.audit.showing': '{count} geladen · {total} totaal',
'admin.audit.col.time': 'Tijd',
'admin.audit.col.user': 'Gebruiker',
'admin.audit.col.action': 'Actie',
'admin.audit.col.resource': 'Bron',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Release-geschiedenis', 'admin.github.title': 'Release-geschiedenis',
'admin.github.subtitle': 'Laatste updates van {repo}', 'admin.github.subtitle': 'Laatste updates van {repo}',
'admin.github.latest': 'Nieuwste', 'admin.github.latest': 'Nieuwste',
@@ -475,6 +617,14 @@ const nl: Record<string, string> = {
'vacay.carriedOver': 'van {year}', 'vacay.carriedOver': 'van {year}',
'vacay.blockWeekends': 'Weekenden blokkeren', 'vacay.blockWeekends': 'Weekenden blokkeren',
'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag', 'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
'vacay.weekendDays': 'Weekenddagen',
'vacay.mon': 'Ma',
'vacay.tue': 'Di',
'vacay.wed': 'Wo',
'vacay.thu': 'Do',
'vacay.fri': 'Vr',
'vacay.sat': 'Za',
'vacay.sun': 'Zo',
'vacay.publicHolidays': 'Feestdagen', 'vacay.publicHolidays': 'Feestdagen',
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender', 'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
'vacay.selectCountry': 'Selecteer land', 'vacay.selectCountry': 'Selecteer land',
@@ -569,6 +719,9 @@ const nl: Record<string, string> = {
'atlas.markVisited': 'Markeren als bezocht', 'atlas.markVisited': 'Markeren als bezocht',
'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.searchCountry': 'Zoek een land...',
'atlas.month': 'Maand',
'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?',
@@ -581,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',
@@ -618,14 +772,31 @@ const nl: Record<string, string> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF', 'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
'dayplan.pdfError': 'PDF-export mislukt', 'dayplan.pdfError': 'PDF-export mislukt',
'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
'dayplan.confirmRemoveTimeBody': 'Deze plek heeft een vast tijdstip ({time}). Verplaatsen verwijdert het tijdstip en maakt vrije sortering mogelijk.',
'dayplan.confirmRemoveTimeAction': 'Tijd verwijderen en verplaatsen',
'dayplan.cannotDropOnTimed': 'Items kunnen niet tussen tijdgebonden items worden geplaatst',
'dayplan.cannotBreakChronology': 'Dit zou de chronologische volgorde van geplande items en boekingen doorbreken',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen', 'places.addPlace': 'Plaats/activiteit toevoegen',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'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.assignToDay': 'Aan welke dag toevoegen?', 'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle', 'places.all': 'Alle',
'places.unplanned': 'Ongepland', 'places.unplanned': 'Ongepland',
'places.search': 'Plaatsen zoeken...', 'places.search': 'Plaatsen zoeken...',
'places.allCategories': 'Alle categorieën', 'places.allCategories': 'Alle categorieën',
'places.categoriesSelected': 'categorieën',
'places.clearFilter': 'Filter wissen',
'places.count': '{count} plaatsen', 'places.count': '{count} plaatsen',
'places.countSingular': '1 plaats', 'places.countSingular': '1 plaats',
'places.allPlanned': 'Alle plaatsen zijn gepland', 'places.allPlanned': 'Alle plaatsen zijn gepland',
@@ -674,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',
@@ -724,6 +896,8 @@ const nl: Record<string, string> = {
'reservations.type.tour': 'Rondleiding', 'reservations.type.tour': 'Rondleiding',
'reservations.type.other': 'Overig', 'reservations.type.other': 'Overig',
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?', 'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
'reservations.confirm.deleteBody': '"{name}" wordt permanent verwijderd.',
'reservations.toast.updated': 'Reservering bijgewerkt', 'reservations.toast.updated': 'Reservering bijgewerkt',
'reservations.toast.removed': 'Reservering verwijderd', 'reservations.toast.removed': 'Reservering verwijderd',
'reservations.toast.fileUploaded': 'Bestand geüpload', 'reservations.toast.fileUploaded': 'Bestand geüpload',
@@ -743,6 +917,7 @@ const nl: Record<string, string> = {
'reservations.pendingSave': 'wordt opgeslagen…', 'reservations.pendingSave': 'wordt opgeslagen…',
'reservations.uploading': 'Uploaden...', 'reservations.uploading': 'Uploaden...',
'reservations.attachFile': 'Bestand bijvoegen', 'reservations.attachFile': 'Bestand bijvoegen',
'reservations.linkExisting': 'Bestaand bestand koppelen',
'reservations.toast.saveError': 'Opslaan mislukt', 'reservations.toast.saveError': 'Opslaan mislukt',
'reservations.toast.updateError': 'Bijwerken mislukt', 'reservations.toast.updateError': 'Bijwerken mislukt',
'reservations.toast.deleteError': 'Verwijderen mislukt', 'reservations.toast.deleteError': 'Verwijderen mislukt',
@@ -753,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...',
@@ -767,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',
@@ -780,6 +957,9 @@ const nl: Record<string, string> = {
'budget.paid': 'Betaald', 'budget.paid': 'Betaald',
'budget.open': 'Open', 'budget.open': 'Open',
'budget.noMembers': 'Geen leden toegewezen', 'budget.noMembers': 'Geen leden toegewezen',
'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
// Files // Files
'files.title': 'Bestanden', 'files.title': 'Bestanden',
@@ -833,6 +1013,15 @@ const nl: Record<string, string> = {
// Packing // Packing
'packing.title': 'Paklijst', 'packing.title': 'Paklijst',
'packing.empty': 'Paklijst is leeg', 'packing.empty': 'Paklijst is leeg',
'packing.import': 'Importeren',
'packing.importTitle': 'Paklijst importeren',
'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal',
'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importeren',
'packing.importSuccess': '{count} items geïmporteerd',
'packing.importError': 'Import mislukt',
'packing.importEmpty': 'Geen items om te importeren',
'packing.progress': '{packed} van {total} ingepakt ({percent}%)', 'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
'packing.clearChecked': '{count} aangevinkte verwijderen', 'packing.clearChecked': '{count} aangevinkte verwijderen',
'packing.clearCheckedShort': '{count} verwijderen', 'packing.clearCheckedShort': '{count} verwijderen',
@@ -984,7 +1173,27 @@ const nl: Record<string, string> = {
'backup.auto.enable': 'Auto-back-up inschakelen', 'backup.auto.enable': 'Auto-back-up inschakelen',
'backup.auto.enableHint': 'Back-ups worden automatisch aangemaakt volgens het gekozen schema', 'backup.auto.enableHint': 'Back-ups worden automatisch aangemaakt volgens het gekozen schema',
'backup.auto.interval': 'Interval', 'backup.auto.interval': 'Interval',
'backup.auto.hour': 'Uitvoeren om',
'backup.auto.hourHint': 'Lokale servertijd ({format}-notatie)',
'backup.auto.dayOfWeek': 'Dag van de week',
'backup.auto.dayOfMonth': 'Dag van de maand',
'backup.auto.dayOfMonthHint': 'Beperkt tot 128 voor compatibiliteit met alle maanden',
'backup.auto.scheduleSummary': 'Planning',
'backup.auto.summaryDaily': 'Elke dag om {hour}:00',
'backup.auto.summaryWeekly': 'Elke {day} om {hour}:00',
'backup.auto.summaryMonthly': 'Dag {day} van elke maand om {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-back-up is geconfigureerd via Docker-omgevingsvariabelen. Pas je docker-compose.yml aan en herstart de container om deze instellingen te wijzigen.',
'backup.auto.copyEnv': 'Docker-omgevingsvariabelen kopiëren',
'backup.auto.envCopied': 'Docker-omgevingsvariabelen gekopieerd naar klembord',
'backup.auto.keepLabel': 'Oude back-ups verwijderen na', 'backup.auto.keepLabel': 'Oude back-ups verwijderen na',
'backup.dow.sunday': 'Zo',
'backup.dow.monday': 'Ma',
'backup.dow.tuesday': 'Di',
'backup.dow.wednesday': 'Wo',
'backup.dow.thursday': 'Do',
'backup.dow.friday': 'Vr',
'backup.dow.saturday': 'Za',
'backup.interval.hourly': 'Elk uur', 'backup.interval.hourly': 'Elk uur',
'backup.interval.daily': 'Dagelijks', 'backup.interval.daily': 'Dagelijks',
'backup.interval.weekly': 'Wekelijks', 'backup.interval.weekly': 'Wekelijks',
@@ -1111,6 +1320,52 @@ const nl: Record<string, string> = {
'day.editAccommodation': 'Accommodatie bewerken', 'day.editAccommodation': 'Accommodatie bewerken',
'day.reservations': 'Reserveringen', 'day.reservations': 'Reserveringen',
// Memories / Immich
'memories.title': 'Foto\'s',
'memories.notConnected': 'Immich niet verbonden',
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
'memories.noPhotos': 'Geen foto\'s gevonden',
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
'memories.photosFound': 'foto\'s',
'memories.fromOthers': 'van anderen',
'memories.sharePhotos': 'Foto\'s delen',
'memories.sharing': 'Wordt gedeeld',
'memories.reviewTitle': 'Je foto\'s bekijken',
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
'memories.shareCount': '{count} foto\'s delen',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-sleutel',
'memories.testConnection': 'Verbinding testen',
'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden',
'memories.connectionSuccess': 'Verbonden met Immich',
'memories.connectionError': 'Kon niet verbinden met Immich',
'memories.saved': 'Immich-instellingen opgeslagen',
'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties',
'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.selectHint': 'Tik op foto\'s om ze te selecteren.',
'memories.selected': 'geselecteerd',
'memories.addSelected': '{count} foto\'s toevoegen',
'memories.alreadyAdded': 'Toegevoegd',
'memories.private': 'Privé',
'memories.stopSharing': 'Delen stoppen',
'memories.tripDates': 'Reisdata',
'memories.allPhotos': 'Alle foto\'s',
'memories.confirmShareTitle': 'Delen met reisgenoten?',
'memories.confirmShareHint': '{count} foto\'s worden zichtbaar voor alle leden van deze reis. Je kunt individuele foto\'s later privé maken.',
'memories.confirmShareButton': 'Foto\'s delen',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities', 'collab.tabs.notes': 'Notities',
@@ -1129,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',
@@ -1179,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
+309 -4
View File
@@ -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': 'Загрузка…',
@@ -139,8 +148,94 @@ const ru: Record<string, string> = {
'settings.temperature': 'Единица температуры', 'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени', 'settings.timeFormat': 'Формат времени',
'settings.routeCalculation': 'Расчёт маршрута', 'settings.routeCalculation': 'Расчёт маршрута',
'settings.blurBookingCodes': 'Скрыть коды бронирования',
'settings.notifications': 'Уведомления',
'settings.notifyTripInvite': 'Приглашения в поездку',
'settings.notifyBookingChange': 'Изменения бронирований',
'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
'settings.notifyPackingTagged': 'Список вещей: назначения',
'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.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
'admin.smtp.testButton': 'Отправить тестовое письмо',
'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
'share.linkTitle': 'Публичная ссылка',
'share.linkHint': 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.',
'share.createLink': 'Создать ссылку',
'share.deleteLink': 'Удалить ссылку',
'share.createError': 'Не удалось создать ссылку',
'common.copy': 'Копировать',
'common.copied': 'Скопировано',
'share.permMap': 'Карта и план',
'share.permBookings': 'Бронирования',
'share.permPacking': 'Вещи',
'shared.expired': 'Ссылка устарела или недействительна',
'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
'shared.readOnly': 'Режим только для чтения',
'shared.tabPlan': 'План',
'shared.tabBookings': 'Бронирования',
'shared.tabPacking': 'Багаж',
'shared.tabBudget': 'Бюджет',
'shared.tabChat': 'Чат',
'shared.days': 'дней',
'shared.places': 'мест',
'shared.other': 'Прочее',
'shared.totalBudget': 'Общий бюджет',
'shared.messages': 'сообщений',
'shared.sharedVia': 'Поделено через',
'shared.confirmed': 'Подтверждено',
'shared.pending': 'Ожидает',
'share.permBudget': 'Бюджет',
'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': 'Эл. почта',
@@ -148,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': 'Новый пароль',
@@ -156,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': 'Удалить ваш аккаунт?',
@@ -168,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': 'Настроить аутентификатор',
@@ -219,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': 'Создание…',
@@ -245,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': 'Создайте аккаунт и начните планировать поездки мечты.',
@@ -271,6 +377,7 @@ const ru: Record<string, string> = {
'admin.tabs.users': 'Пользователи', 'admin.tabs.users': 'Пользователи',
'admin.tabs.categories': 'Категории', 'admin.tabs.categories': 'Категории',
'admin.tabs.backup': 'Резервная копия', 'admin.tabs.backup': 'Резервная копия',
'admin.tabs.audit': 'Журнал аудита',
'admin.stats.users': 'Пользователи', 'admin.stats.users': 'Пользователи',
'admin.stats.trips': 'Поездки', 'admin.stats.trips': 'Поездки',
'admin.stats.places': 'Места', 'admin.stats.places': 'Места',
@@ -320,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',
@@ -373,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': 'Бюджет',
@@ -393,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': 'Нет доступных дополнений',
@@ -410,8 +523,37 @@ 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',
'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).',
'admin.audit.empty': 'Записей аудита пока нет.',
'admin.audit.refresh': 'Обновить',
'admin.audit.loadMore': 'Загрузить ещё',
'admin.audit.showing': 'Загружено: {count} · всего {total}',
'admin.audit.col.time': 'Время',
'admin.audit.col.user': 'Пользователь',
'admin.audit.col.action': 'Действие',
'admin.audit.col.resource': 'Объект',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Подробности',
'admin.github.title': 'История релизов', 'admin.github.title': 'История релизов',
'admin.github.subtitle': 'Последние обновления из {repo}', 'admin.github.subtitle': 'Последние обновления из {repo}',
'admin.github.latest': 'Последний', 'admin.github.latest': 'Последний',
@@ -475,6 +617,14 @@ const ru: Record<string, string> = {
'vacay.carriedOver': 'из {year}', 'vacay.carriedOver': 'из {year}',
'vacay.blockWeekends': 'Блокировать выходные', 'vacay.blockWeekends': 'Блокировать выходные',
'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье', 'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
'vacay.weekendDays': 'Выходные дни',
'vacay.mon': 'Пн',
'vacay.tue': 'Вт',
'vacay.wed': 'Ср',
'vacay.thu': 'Чт',
'vacay.fri': 'Пт',
'vacay.sat': 'Сб',
'vacay.sun': 'Вс',
'vacay.publicHolidays': 'Государственные праздники', 'vacay.publicHolidays': 'Государственные праздники',
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре', 'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
'vacay.selectCountry': 'Выберите страну', 'vacay.selectCountry': 'Выберите страну',
@@ -569,6 +719,9 @@ const ru: Record<string, string> = {
'atlas.markVisited': 'Отметить как посещённую', 'atlas.markVisited': 'Отметить как посещённую',
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых', 'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
'atlas.addToBucket': 'В список желаний', 'atlas.addToBucket': 'В список желаний',
'atlas.addPoi': 'Добавить место',
'atlas.searchCountry': 'Поиск страны...',
'atlas.month': 'Месяц',
'atlas.addToBucketHint': 'Сохранить как место для посещения', 'atlas.addToBucketHint': 'Сохранить как место для посещения',
'atlas.bucketWhen': 'Когда вы планируете поехать?', 'atlas.bucketWhen': 'Когда вы планируете поехать?',
@@ -581,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': 'Место обновлено',
@@ -618,14 +772,31 @@ const ru: Record<string, string> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF', 'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
'dayplan.pdfError': 'Ошибка экспорта PDF', 'dayplan.pdfError': 'Ошибка экспорта PDF',
'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
'dayplan.confirmRemoveTimeBody': 'У этого места фиксированное время ({time}). При перемещении время будет удалено, и станет доступна свободная сортировка.',
'dayplan.confirmRemoveTimeAction': 'Удалить время и переместить',
'dayplan.cannotDropOnTimed': 'Элементы нельзя размещать между записями с фиксированным временем',
'dayplan.cannotBreakChronology': 'Это нарушит хронологический порядок запланированных элементов и бронирований',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Добавить место/активность', 'places.addPlace': 'Добавить место/активность',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} мест импортировано из 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.assignToDay': 'Добавить в какой день?', 'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все', 'places.all': 'Все',
'places.unplanned': 'Незапланированные', 'places.unplanned': 'Незапланированные',
'places.search': 'Поиск мест...', 'places.search': 'Поиск мест...',
'places.allCategories': 'Все категории', 'places.allCategories': 'Все категории',
'places.categoriesSelected': 'категорий',
'places.clearFilter': 'Сбросить фильтр',
'places.count': '{count} мест', 'places.count': '{count} мест',
'places.countSingular': '1 место', 'places.countSingular': '1 место',
'places.allPlanned': 'Все места запланированы', 'places.allPlanned': 'Все места запланированы',
@@ -674,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': 'Бронирования',
@@ -724,6 +896,8 @@ const ru: Record<string, string> = {
'reservations.type.tour': 'Экскурсия', 'reservations.type.tour': 'Экскурсия',
'reservations.type.other': 'Другое', 'reservations.type.other': 'Другое',
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?', 'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
'reservations.confirm.deleteTitle': 'Удалить бронирование?',
'reservations.confirm.deleteBody': '«{name}» будет удалено навсегда.',
'reservations.toast.updated': 'Бронирование обновлено', 'reservations.toast.updated': 'Бронирование обновлено',
'reservations.toast.removed': 'Бронирование удалено', 'reservations.toast.removed': 'Бронирование удалено',
'reservations.toast.fileUploaded': 'Файл загружен', 'reservations.toast.fileUploaded': 'Файл загружен',
@@ -743,6 +917,7 @@ const ru: Record<string, string> = {
'reservations.pendingSave': 'будет сохранено…', 'reservations.pendingSave': 'будет сохранено…',
'reservations.uploading': 'Загрузка...', 'reservations.uploading': 'Загрузка...',
'reservations.attachFile': 'Прикрепить файл', 'reservations.attachFile': 'Прикрепить файл',
'reservations.linkExisting': 'Привязать существующий файл',
'reservations.toast.saveError': 'Ошибка сохранения', 'reservations.toast.saveError': 'Ошибка сохранения',
'reservations.toast.updateError': 'Ошибка обновления', 'reservations.toast.updateError': 'Ошибка обновления',
'reservations.toast.deleteError': 'Ошибка удаления', 'reservations.toast.deleteError': 'Ошибка удаления',
@@ -753,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': 'Введите название категории...',
@@ -767,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': 'Новая категория',
@@ -780,6 +957,9 @@ const ru: Record<string, string> = {
'budget.paid': 'Оплачено', 'budget.paid': 'Оплачено',
'budget.open': 'Не оплачено', 'budget.open': 'Не оплачено',
'budget.noMembers': 'Участники не назначены', 'budget.noMembers': 'Участники не назначены',
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
// Files // Files
'files.title': 'Файлы', 'files.title': 'Файлы',
@@ -833,6 +1013,15 @@ const ru: Record<string, string> = {
// Packing // Packing
'packing.title': 'Список вещей', 'packing.title': 'Список вещей',
'packing.empty': 'Список вещей пуст', 'packing.empty': 'Список вещей пуст',
'packing.import': 'Импорт',
'packing.importTitle': 'Импорт списка вещей',
'packing.importHint': 'Один предмет на строку. Категория и количество — через запятую, точку с запятой или табуляцию: Название, Категория, Количество',
'packing.importPlaceholder': 'Зубная щётка\nСолнцезащитный крем, Гигиена\nФутболки, Одежда, 5\nПаспорт, Документы',
'packing.importCsv': 'Загрузить CSV/TXT',
'packing.importAction': 'Импортировать {count}',
'packing.importSuccess': '{count} предметов импортировано',
'packing.importError': 'Ошибка импорта',
'packing.importEmpty': 'Нет предметов для импорта',
'packing.progress': '{packed} из {total} собрано ({percent}%)', 'packing.progress': '{packed} из {total} собрано ({percent}%)',
'packing.clearChecked': 'Удалить {count} отмеченных', 'packing.clearChecked': 'Удалить {count} отмеченных',
'packing.clearCheckedShort': 'Удалить {count}', 'packing.clearCheckedShort': 'Удалить {count}',
@@ -984,7 +1173,27 @@ const ru: Record<string, string> = {
'backup.auto.enable': 'Включить автокопирование', 'backup.auto.enable': 'Включить автокопирование',
'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию', 'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию',
'backup.auto.interval': 'Интервал', 'backup.auto.interval': 'Интервал',
'backup.auto.hour': 'Запуск в час',
'backup.auto.hourHint': 'Местное время сервера (формат {format})',
'backup.auto.dayOfWeek': 'День недели',
'backup.auto.dayOfMonth': 'День месяца',
'backup.auto.dayOfMonthHint': 'Ограничено 1–28 для совместимости со всеми месяцами',
'backup.auto.scheduleSummary': 'Расписание',
'backup.auto.summaryDaily': 'Каждый день в {hour}:00',
'backup.auto.summaryWeekly': 'Каждый {day} в {hour}:00',
'backup.auto.summaryMonthly': '{day}-го числа каждого месяца в {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Автокопирование настроено через переменные окружения Docker. Чтобы изменить параметры, обновите docker-compose.yml и перезапустите контейнер.',
'backup.auto.copyEnv': 'Скопировать переменные окружения Docker',
'backup.auto.envCopied': 'Переменные окружения Docker скопированы в буфер обмена',
'backup.auto.keepLabel': 'Удалять старые копии через', 'backup.auto.keepLabel': 'Удалять старые копии через',
'backup.dow.sunday': 'Вс',
'backup.dow.monday': 'Пн',
'backup.dow.tuesday': 'Вт',
'backup.dow.wednesday': 'Ср',
'backup.dow.thursday': 'Чт',
'backup.dow.friday': 'Пт',
'backup.dow.saturday': 'Сб',
'backup.interval.hourly': 'Каждый час', 'backup.interval.hourly': 'Каждый час',
'backup.interval.daily': 'Ежедневно', 'backup.interval.daily': 'Ежедневно',
'backup.interval.weekly': 'Еженедельно', 'backup.interval.weekly': 'Еженедельно',
@@ -1111,6 +1320,52 @@ const ru: Record<string, string> = {
'day.editAccommodation': 'Редактировать жильё', 'day.editAccommodation': 'Редактировать жильё',
'day.reservations': 'Бронирования', 'day.reservations': 'Бронирования',
// Memories / Immich
'memories.title': 'Фото',
'memories.notConnected': 'Immich не подключён',
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
'memories.noPhotos': 'Фотографии не найдены',
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
'memories.photosFound': 'фото',
'memories.fromOthers': 'от других',
'memories.sharePhotos': 'Поделиться фото',
'memories.sharing': 'Общий доступ',
'memories.reviewTitle': 'Проверьте ваши фото',
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
'memories.shareCount': 'Поделиться ({count} фото)',
'memories.immichUrl': 'URL сервера Immich',
'memories.immichApiKey': 'API-ключ',
'memories.testConnection': 'Проверить подключение',
'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено',
'memories.connectionSuccess': 'Подключение к Immich установлено',
'memories.connectionError': 'Не удалось подключиться к Immich',
'memories.saved': 'Настройки Immich сохранены',
'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото',
'memories.linkAlbum': 'Привязать альбом',
'memories.selectAlbum': 'Выбрать альбом Immich',
'memories.noAlbums': 'Альбомы не найдены',
'memories.syncAlbum': 'Синхронизировать',
'memories.unlinkAlbum': 'Отвязать',
'memories.photos': 'фото',
'memories.selectPhotos': 'Выбрать фото из Immich',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано',
'memories.addSelected': 'Добавить {count} фото',
'memories.alreadyAdded': 'Добавлено',
'memories.private': 'Приватное',
'memories.stopSharing': 'Прекратить доступ',
'memories.tripDates': 'Даты поездки',
'memories.allPhotos': 'Все фото',
'memories.confirmShareTitle': 'Поделиться с участниками поездки?',
'memories.confirmShareHint': '{count} фото станут видны всем участникам этой поездки. Вы сможете сделать отдельные фото приватными позже.',
'memories.confirmShareButton': 'Поделиться фото',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Чат', 'collab.tabs.chat': 'Чат',
'collab.tabs.notes': 'Заметки', 'collab.tabs.notes': 'Заметки',
@@ -1129,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} мин. назад',
@@ -1179,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
+309 -4
View File
@@ -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': '上传中…',
@@ -139,8 +148,94 @@ const zh: Record<string, string> = {
'settings.temperature': '温度单位', 'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式', 'settings.timeFormat': '时间格式',
'settings.routeCalculation': '路线计算', 'settings.routeCalculation': '路线计算',
'settings.blurBookingCodes': '模糊预订代码',
'settings.notifications': '通知',
'settings.notifyTripInvite': '旅行邀请',
'settings.notifyBookingChange': '预订变更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyVacayInvite': 'Vacay 融合邀请',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天消息 (Collab)',
'settings.notifyPackingTagged': '行李清单:分配',
'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.hint': '用于发送电子邮件通知的 SMTP 配置。',
'admin.smtp.testButton': '发送测试邮件',
'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。',
'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败',
'dayplan.icsTooltip': '导出日历 (ICS)',
'share.linkTitle': '公开链接',
'share.linkHint': '创建一个链接,任何人无需登录即可查看此旅行。仅可查看,无法编辑。',
'share.createLink': '创建链接',
'share.deleteLink': '删除链接',
'share.createError': '无法创建链接',
'common.copy': '复制',
'common.copied': '已复制',
'share.permMap': '地图与计划',
'share.permBookings': '预订',
'share.permPacking': '行李',
'shared.expired': '链接已过期或无效',
'shared.expiredHint': '此共享旅行链接已失效。',
'shared.readOnly': '只读共享视图',
'shared.tabPlan': '计划',
'shared.tabBookings': '预订',
'shared.tabPacking': '行李',
'shared.tabBudget': '预算',
'shared.tabChat': '聊天',
'shared.days': '天',
'shared.places': '个地点',
'shared.other': '其他',
'shared.totalBudget': '总预算',
'shared.messages': '条消息',
'shared.sharedVia': '通过以下分享',
'shared.confirmed': '已确认',
'shared.pending': '待确认',
'share.permBudget': '预算',
'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': '邮箱',
@@ -148,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': '新密码',
@@ -156,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': '确定删除账户?',
@@ -168,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': '设置身份验证器',
@@ -219,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': '创建中…',
@@ -245,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': '创建账户,开始规划你的梦想旅行。',
@@ -271,6 +377,7 @@ const zh: Record<string, string> = {
'admin.tabs.users': '用户', 'admin.tabs.users': '用户',
'admin.tabs.categories': '分类', 'admin.tabs.categories': '分类',
'admin.tabs.backup': '备份', 'admin.tabs.backup': '备份',
'admin.tabs.audit': '审计日志',
'admin.stats.users': '用户', 'admin.stats.users': '用户',
'admin.stats.trips': '旅行', 'admin.stats.trips': '旅行',
'admin.stats.places': '地点', 'admin.stats.places': '地点',
@@ -320,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 密钥',
@@ -373,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': '预算',
@@ -393,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': '暂无可用扩展',
@@ -410,8 +523,37 @@ 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',
'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。',
'admin.audit.empty': '暂无审计记录。',
'admin.audit.refresh': '刷新',
'admin.audit.loadMore': '加载更多',
'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条',
'admin.audit.col.time': '时间',
'admin.audit.col.user': '用户',
'admin.audit.col.action': '操作',
'admin.audit.col.resource': '资源',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': '详情',
'admin.github.title': '版本历史', 'admin.github.title': '版本历史',
'admin.github.subtitle': '{repo} 的最新更新', 'admin.github.subtitle': '{repo} 的最新更新',
'admin.github.latest': '最新', 'admin.github.latest': '最新',
@@ -475,6 +617,14 @@ const zh: Record<string, string> = {
'vacay.carriedOver': '从 {year} 结转', 'vacay.carriedOver': '从 {year} 结转',
'vacay.blockWeekends': '锁定周末', 'vacay.blockWeekends': '锁定周末',
'vacay.blockWeekendsHint': '禁止在周六和周日安排假期', 'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
'vacay.weekendDays': '周末',
'vacay.mon': '周一',
'vacay.tue': '周二',
'vacay.wed': '周三',
'vacay.thu': '周四',
'vacay.fri': '周五',
'vacay.sat': '周六',
'vacay.sun': '周日',
'vacay.publicHolidays': '公共假日', 'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日历中标记公共假日', 'vacay.publicHolidaysHint': '在日历中标记公共假日',
'vacay.selectCountry': '选择国家', 'vacay.selectCountry': '选择国家',
@@ -569,6 +719,9 @@ const zh: Record<string, string> = {
'atlas.markVisited': '标记为已访问', 'atlas.markVisited': '标记为已访问',
'atlas.markVisitedHint': '将此国家添加到已访问列表', 'atlas.markVisitedHint': '将此国家添加到已访问列表',
'atlas.addToBucket': '添加到心愿单', 'atlas.addToBucket': '添加到心愿单',
'atlas.addPoi': '添加地点',
'atlas.searchCountry': '搜索国家...',
'atlas.month': '月份',
'atlas.addToBucketHint': '保存为想去的地方', 'atlas.addToBucketHint': '保存为想去的地方',
'atlas.bucketWhen': '你计划什么时候去?', 'atlas.bucketWhen': '你计划什么时候去?',
@@ -581,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': '地点已更新',
@@ -618,14 +772,31 @@ const zh: Record<string, string> = {
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '导出当天计划为 PDF', 'dayplan.pdfTooltip': '导出当天计划为 PDF',
'dayplan.pdfError': 'PDF 导出失败', 'dayplan.pdfError': 'PDF 导出失败',
'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
'dayplan.confirmRemoveTimeTitle': '移除时间?',
'dayplan.confirmRemoveTimeBody': '此地点有固定时间({time})。移动后将移除时间并允许自由排序。',
'dayplan.confirmRemoveTimeAction': '移除时间并移动',
'dayplan.cannotDropOnTimed': '无法将项目放置在有固定时间的条目之间',
'dayplan.cannotBreakChronology': '这将打乱已计划项目和预订的时间顺序',
// Places Sidebar // Places Sidebar
'places.addPlace': '添加地点/活动', 'places.addPlace': '添加地点/活动',
'places.importGpx': 'GPX',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
'places.importGoogleList': 'Google 列表',
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
'places.googleListError': 'Google Maps 列表导入失败',
'places.viewDetails': '查看详情',
'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?', 'places.assignToDay': '添加到哪一天?',
'places.all': '全部', 'places.all': '全部',
'places.unplanned': '未规划', 'places.unplanned': '未规划',
'places.search': '搜索地点...', 'places.search': '搜索地点...',
'places.allCategories': '所有分类', 'places.allCategories': '所有分类',
'places.categoriesSelected': '个分类',
'places.clearFilter': '清除筛选',
'places.count': '{count} 个地点', 'places.count': '{count} 个地点',
'places.countSingular': '1 个地点', 'places.countSingular': '1 个地点',
'places.allPlanned': '所有地点已规划', 'places.allPlanned': '所有地点已规划',
@@ -674,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': '预订',
@@ -724,6 +896,8 @@ const zh: Record<string, string> = {
'reservations.type.tour': '旅游团', 'reservations.type.tour': '旅游团',
'reservations.type.other': '其他', 'reservations.type.other': '其他',
'reservations.confirm.delete': '确定要删除预订「{name}」吗?', 'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
'reservations.confirm.deleteTitle': '删除预订?',
'reservations.confirm.deleteBody': '"{name}" 将被永久删除。',
'reservations.toast.updated': '预订已更新', 'reservations.toast.updated': '预订已更新',
'reservations.toast.removed': '预订已删除', 'reservations.toast.removed': '预订已删除',
'reservations.toast.fileUploaded': '文件已上传', 'reservations.toast.fileUploaded': '文件已上传',
@@ -743,6 +917,7 @@ const zh: Record<string, string> = {
'reservations.pendingSave': '将被保存…', 'reservations.pendingSave': '将被保存…',
'reservations.uploading': '上传中...', 'reservations.uploading': '上传中...',
'reservations.attachFile': '附加文件', 'reservations.attachFile': '附加文件',
'reservations.linkExisting': '关联已有文件',
'reservations.toast.saveError': '保存失败', 'reservations.toast.saveError': '保存失败',
'reservations.toast.updateError': '更新失败', 'reservations.toast.updateError': '更新失败',
'reservations.toast.deleteError': '删除失败', 'reservations.toast.deleteError': '删除失败',
@@ -753,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': '输入分类名称...',
@@ -767,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': '新分类',
@@ -780,6 +957,9 @@ const zh: Record<string, string> = {
'budget.paid': '已支付', 'budget.paid': '已支付',
'budget.open': '未支付', 'budget.open': '未支付',
'budget.noMembers': '未分配成员', 'budget.noMembers': '未分配成员',
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
// Files // Files
'files.title': '文件', 'files.title': '文件',
@@ -833,6 +1013,15 @@ const zh: Record<string, string> = {
// Packing // Packing
'packing.title': '行李清单', 'packing.title': '行李清单',
'packing.empty': '行李清单为空', 'packing.empty': '行李清单为空',
'packing.import': '导入',
'packing.importTitle': '导入装箱清单',
'packing.importHint': '每行一个物品。可选用逗号、分号或制表符分隔类别和数量:名称, 类别, 数量',
'packing.importPlaceholder': '牙刷\n防晒霜, 卫生\nT恤, 衣物, 5\n护照, 证件',
'packing.importCsv': '加载 CSV/TXT',
'packing.importAction': '导入 {count}',
'packing.importSuccess': '已导入 {count} 项',
'packing.importError': '导入失败',
'packing.importEmpty': '没有可导入的项目',
'packing.progress': '已打包 {packed}/{total}{percent}%', 'packing.progress': '已打包 {packed}/{total}{percent}%',
'packing.clearChecked': '移除 {count} 个已勾选', 'packing.clearChecked': '移除 {count} 个已勾选',
'packing.clearCheckedShort': '移除 {count} 个', 'packing.clearCheckedShort': '移除 {count} 个',
@@ -984,7 +1173,27 @@ const zh: Record<string, string> = {
'backup.auto.enable': '启用自动备份', 'backup.auto.enable': '启用自动备份',
'backup.auto.enableHint': '将按所选计划自动创建备份', 'backup.auto.enableHint': '将按所选计划自动创建备份',
'backup.auto.interval': '间隔', 'backup.auto.interval': '间隔',
'backup.auto.hour': '执行时间',
'backup.auto.hourHint': '服务器本地时间({format} 格式)',
'backup.auto.dayOfWeek': '星期几',
'backup.auto.dayOfMonth': '每月几号',
'backup.auto.dayOfMonthHint': '限 128 以兼容所有月份',
'backup.auto.scheduleSummary': '计划',
'backup.auto.summaryDaily': '每天 {hour}:00',
'backup.auto.summaryWeekly': '每{day} {hour}:00',
'backup.auto.summaryMonthly': '每月 {day} 号 {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': '自动备份通过 Docker 环境变量配置。要更改设置,请更新 docker-compose.yml 并重启容器。',
'backup.auto.copyEnv': '复制 Docker 环境变量',
'backup.auto.envCopied': 'Docker 环境变量已复制到剪贴板',
'backup.auto.keepLabel': '自动删除旧备份', 'backup.auto.keepLabel': '自动删除旧备份',
'backup.dow.sunday': '周日',
'backup.dow.monday': '周一',
'backup.dow.tuesday': '周二',
'backup.dow.wednesday': '周三',
'backup.dow.thursday': '周四',
'backup.dow.friday': '周五',
'backup.dow.saturday': '周六',
'backup.interval.hourly': '每小时', 'backup.interval.hourly': '每小时',
'backup.interval.daily': '每天', 'backup.interval.daily': '每天',
'backup.interval.weekly': '每周', 'backup.interval.weekly': '每周',
@@ -1111,6 +1320,52 @@ const zh: Record<string, string> = {
'day.editAccommodation': '编辑住宿', 'day.editAccommodation': '编辑住宿',
'day.reservations': '预订', 'day.reservations': '预订',
// Memories / Immich
'memories.title': '照片',
'memories.notConnected': 'Immich 未连接',
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
'memories.noDates': '为旅行添加日期以加载照片。',
'memories.noPhotos': '未找到照片',
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
'memories.photosFound': '张照片',
'memories.fromOthers': '来自他人',
'memories.sharePhotos': '分享照片',
'memories.sharing': '分享中',
'memories.reviewTitle': '审查您的照片',
'memories.reviewHint': '点击照片以将其从分享中排除。',
'memories.shareCount': '分享 {count} 张照片',
'memories.immichUrl': 'Immich 服务器地址',
'memories.immichApiKey': 'API 密钥',
'memories.testConnection': '测试连接',
'memories.testFirst': '请先测试连接',
'memories.connected': '已连接',
'memories.disconnected': '未连接',
'memories.connectionSuccess': '已连接到 Immich',
'memories.connectionError': '无法连接到 Immich',
'memories.saved': 'Immich 设置已保存',
'memories.oldest': '最早优先',
'memories.newest': '最新优先',
'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片',
'memories.linkAlbum': '关联相册',
'memories.selectAlbum': '选择 Immich 相册',
'memories.noAlbums': '未找到相册',
'memories.syncAlbum': '同步相册',
'memories.unlinkAlbum': '取消关联',
'memories.photos': '张照片',
'memories.selectPhotos': '从 Immich 选择照片',
'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择',
'memories.addSelected': '添加 {count} 张照片',
'memories.alreadyAdded': '已添加',
'memories.private': '私密',
'memories.stopSharing': '停止分享',
'memories.tripDates': '旅行日期',
'memories.allPhotos': '所有照片',
'memories.confirmShareTitle': '与旅行成员分享?',
'memories.confirmShareHint': '{count} 张照片将对本次旅行的所有成员可见。你可以稍后将单张照片设为私密。',
'memories.confirmShareButton': '分享照片',
// Collab Addon // Collab Addon
'collab.tabs.chat': '聊天', 'collab.tabs.chat': '聊天',
'collab.tabs.notes': '笔记', 'collab.tabs.notes': '笔记',
@@ -1129,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} 分钟前',
@@ -1179,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
+421 -200
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { adminApi, authApi } 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'
@@ -13,7 +14,10 @@ import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel' 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 { 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 AuditLogPanel from '../components/Admin/AuditLogPanel'
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 {
@@ -41,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 {
@@ -52,15 +57,18 @@ interface UpdateInfo {
} }
export default function AdminPage(): React.ReactElement { export default function AdminPage(): React.ReactElement {
const { demoMode } = 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') },
{ id: 'addons', label: t('admin.tabs.addons') }, { id: 'addons', label: t('admin.tabs.addons') },
{ 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') },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
{ id: 'github', label: t('admin.tabs.github') }, { id: 'github', label: t('admin.tabs.github') },
] ]
@@ -78,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[]>([])
@@ -93,6 +102,16 @@ export default function AdminPage(): React.ReactElement {
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false) const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// SMTP settings
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
const [smtpLoaded, setSmtpLoaded] = useState(false)
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
// API Keys // API Keys
const [mapsKey, setMapsKey] = useState<string>('') const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('') const [weatherKey, setWeatherKey] = useState<string>('')
@@ -104,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()
@@ -143,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
@@ -159,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 {
@@ -189,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] }))
} }
@@ -241,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])
@@ -296,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)
@@ -333,7 +356,7 @@ export default function AdminPage(): React.ReactElement {
<Shield className="w-5 h-5 text-slate-700" /> <Shield className="w-5 h-5 text-slate-700" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Administration</h1> <h1 className="text-2xl font-bold text-slate-900">{t('admin.title')}</h1>
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p> <p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
</div> </div>
</div> </div>
@@ -364,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>
)} )}
@@ -512,10 +525,10 @@ export default function AdminPage(): React.ReactElement {
</span> </span>
</td> </td>
<td className="px-5 py-3 text-sm text-slate-500"> <td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale)} {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
</td> </td>
<td className="px-5 py-3 text-sm text-slate-500"> <td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'} {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
</td> </td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
@@ -584,7 +597,7 @@ export default function AdminPage(): React.ReactElement {
</div> </div>
<div className="text-xs text-slate-400 mt-0.5"> <div className="text-xs text-slate-400 mt-0.5">
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} {inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`} {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div> </div>
</div> </div>
@@ -606,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">
@@ -680,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>
@@ -857,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
@@ -884,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>
@@ -900,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'))
@@ -918,11 +966,222 @@ export default function AdminPage(): React.ReactElement {
</button> </button>
</div> </div>
</div> </div>
{/* Notifications — exclusive channel selector */}
<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.notifications.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
</div>
<div className="p-6 space-y-4">
{/* Channel selector */}
<div className="flex gap-2">
{(['none', 'email', 'webhook'] as const).map(ch => {
const active = (smtpValues.notification_channel || 'none') === ch
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
return (
<button
key={ch}
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
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'}`}
>
{labels[ch]}
</button>
)
})}
</div>
{/* 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>
)
})()}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
].map(field => (
<div key={field.key}>
<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>
)} )}
{activeTab === 'backup' && <BackupPanel />} {activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'github' && <GitHubPanel />} {activeTab === 'github' && <GitHubPanel />}
</div> </div>
</div> </div>
@@ -1063,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 \\
@@ -1143,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 &amp; 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>
) )
} }
+324 -25
View File
@@ -1,11 +1,11 @@
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'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client' import apiClient, { mapsApi } from '../api/client'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2 } from 'lucide-react' import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import L from 'leaflet' import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types' import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
@@ -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'
@@ -154,17 +155,42 @@ export default function AtlasPage(): React.ReactElement {
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null) const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null) const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null) const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(new Date().getMonth() + 1) const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(new Date().getFullYear()) const [bucketYear, setBucketYear] = useState(0)
// Bucket list // Bucket list
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null } interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
const [bucketList, setBucketList] = useState<BucketItem[]>([]) const [bucketList, setBucketList] = useState<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false) const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '' }) const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
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([
@@ -179,7 +205,7 @@ export default function AtlasPage(): React.ReactElement {
// Load GeoJSON world data (direct GeoJSON, no conversion needed) // Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => { useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson') fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json()) .then(r => r.json())
.then(geo => { .then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON // Dynamically build A2→A3 mapping from GeoJSON
@@ -226,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
@@ -287,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 = `
@@ -332,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
@@ -361,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
@@ -397,9 +441,15 @@ export default function AtlasPage(): React.ReactElement {
const handleAddBucketItem = async (): Promise<void> => { const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return if (!bucketForm.name.trim()) return
try { try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: bucketForm.name.trim(), notes: bucketForm.notes.trim() || null }) const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev]) setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '' }) setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false) setShowBucketAdd(false)
} catch { /* */ } } catch { /* */ }
} }
@@ -411,6 +461,28 @@ export default function AtlasPage(): React.ReactElement {
} catch { /* */ } } catch { /* */ }
} }
const handleBucketPoiSearch = async () => {
if (!bucketSearch.trim()) return
setBucketSearching(true)
try {
const result = await mapsApi.search(bucketSearch, language)
setBucketSearchResults(result.places || [])
} catch {} finally { setBucketSearching(false) }
}
const handleSelectBucketPoi = (result: any) => {
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
setBucketForm({
name: result.name || bucketSearch,
notes: '',
lat: String(result.lat || ''),
lng: String(result.lng || ''),
target_date: targetDate || '',
})
setBucketSearchResults([])
setBucketSearch('')
}
// Render bucket list markers on map // Render bucket list markers on map
useEffect(() => { useEffect(() => {
if (!mapInstance.current) return if (!mapInstance.current) return
@@ -461,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' }}>
@@ -517,6 +712,10 @@ export default function AtlasPage(): React.ReactElement {
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd} showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
bucketForm={bucketForm} setBucketForm={setBucketForm} bucketForm={bucketForm} setBucketForm={setBucketForm}
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem} onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
t={t} dark={dark} t={t} dark={dark}
/> />
</div> </div>
@@ -592,32 +791,41 @@ 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))}
options={Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) }))} placeholder={t('atlas.month')}
options={[
{ value: '0', label: '—' },
...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))}
options={Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))} placeholder={t('atlas.year')}
options={[
{ value: '0', label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
]}
size="sm" size="sm"
/> />
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })} <button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.back')} {t('common.back')}
</button> </button>
<button onClick={async () => { <button onClick={async () => {
const monthStr = new Date(bucketYear, bucketMonth - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
try { try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, notes: monthStr }) const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev]) setBucketList(prev => [r.data.item, ...prev])
} catch {} } catch {}
setBucketMonth(0); setBucketYear(0)
setConfirmAction(null) setConfirmAction(null)
}} }}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}> style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}>
@@ -664,15 +872,27 @@ interface SidebarContentProps {
setBucketTab: (tab: 'stats' | 'bucket') => void setBucketTab: (tab: 'stats' | 'bucket') => void
showBucketAdd: boolean showBucketAdd: boolean
setShowBucketAdd: (v: boolean) => void setShowBucketAdd: (v: boolean) => void
bucketForm: { name: string; notes: string } bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string }
setBucketForm: (f: { name: string; notes: string }) => void setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
onAddBucket: () => Promise<void> onAddBucket: () => Promise<void>
onDeleteBucket: (id: number) => Promise<void> onDeleteBucket: (id: number) => Promise<void>
onSearchBucket: () => Promise<void>
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
setBucketSearchResults: (v: string[]) => void
bucketPoiMonth: number
setBucketPoiMonth: (v: number) => void
bucketPoiYear: number
setBucketPoiYear: (v: number) => void
bucketSearching: boolean
bucketSearch: string
setBucketSearch: (v: string) => void
t: TranslationFn t: TranslationFn
dark: boolean dark: boolean
} }
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, 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 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'
const tm = dark ? '#94a3b8' : '#64748b' const tm = dark ? '#94a3b8' : '#64748b'
@@ -722,6 +942,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// Bucket list content // Bucket list content
const bucketContent = ( const bucketContent = (
<>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}> <div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
{bucketList.map(item => ( {bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}> <div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
@@ -732,7 +953,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
) : <Star size={16} style={{ color: '#fbbf24', marginBottom: 4 }} fill="#fbbf24" /> ) : <Star size={16} style={{ color: '#fbbf24', marginBottom: 4 }} fill="#fbbf24" />
})()} })()}
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span> <span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
{item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>} {item.target_date && (() => {
const [y, m] = item.target_date.split('-')
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
})()}
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
<button onClick={() => onDeleteBucket(item.id)} <button onClick={() => onDeleteBucket(item.id)}
className="opacity-0 group-hover:opacity-100" className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}> style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
@@ -740,12 +966,85 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</button> </button>
</div> </div>
))} ))}
{bucketList.length === 0 && ( {bucketList.length === 0 && !showBucketAdd && (
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}> <div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
{t('atlas.bucketEmptyHint')} {t('atlas.bucketEmptyHint')}
</div> </div>
)} )}
</div> </div>
{showBucketAdd ? (
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Search or manual name */}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={bucketForm.name || bucketSearch}
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
placeholder={t('atlas.bucketNamePlaceholder')}
autoFocus
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/>
{!bucketForm.name && (
<button onClick={onSearchBucket} disabled={bucketSearching}
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Search size={12} />
</button>
)}
{bucketForm.name && (
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={12} />
</button>
)}
</div>
{bucketSearchResults.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{bucketSearchResults.slice(0, 6).map((r, i) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
</button>
))}
</div>
)}
</div>
{/* Selected place indicator */}
{bucketForm.lat && bucketForm.lng && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
</div>
)}
{/* Month / Year with CustomSelect */}
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<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: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
</div>
<div style={{ flex: 1 }}>
<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: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />
</div>
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
style={{ fontSize: 11, padding: '4px 12px', borderRadius: 6, border: 'none', background: '#fbbf24', color: '#1a1a1a', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: bucketForm.name.trim() ? 1 : 0.5 }}>
{t('common.add')}
</button>
</div>
</div>
) : (
<div style={{ padding: '4px 16px 8px' }}>
<button onClick={() => setShowBucketAdd(true)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={11} /> {t('atlas.addPoi')}
</button>
</div>
)}
</>
) )
return ( return (
+71 -49
View File
@@ -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 }
+113 -44
View File
@@ -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,23 +29,17 @@ 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()
useEffect(() => { useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
// Handle query params (invite token, OIDC callback)
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
// Check for invite token in URL (/register?invite=xxx or /login?invite=xxx)
const invite = params.get('invite') const invite = params.get('invite')
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (invite) { if (invite) {
setInviteToken(invite) setInviteToken(invite)
setMode('register') setMode('register')
@@ -54,26 +49,27 @@ export default function LoginPage(): React.ReactElement {
setError('Invalid or expired invite link') setError('Invalid or expired invite link')
}) })
window.history.replaceState({}, '', window.location.pathname) window.history.replaceState({}, '', window.location.pathname)
return
} }
// Handle OIDC callback via short-lived auth code (secure exchange)
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (oidcCode) { if (oidcCode) {
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') navigate('/dashboard', { replace: true })
window.location.reload()
} else { } else {
setError(data.error || 'OIDC login failed') setError(data.error || 'OIDC login failed')
} }
}) })
.catch(() => setError('OIDC login failed')) .catch(() => setError('OIDC login failed'))
.finally(() => setIsLoading(false))
return
} }
if (oidcError) { if (oidcError) {
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'), registration_disabled: t('login.oidc.registrationDisabled'),
@@ -83,8 +79,19 @@ export default function LoginPage(): React.ReactElement {
} }
setError(errorMessages[oidcError] || oidcError) setError(errorMessages[oidcError] || oidcError)
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
return
} }
}, [])
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t])
const handleDemoLogin = async (): Promise<void> => { const handleDemoLogin = async (): Promise<void> => {
setError('') setError('')
@@ -104,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)
@@ -134,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)
@@ -143,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
@@ -490,7 +523,7 @@ export default function LoginPage(): React.ReactElement {
{error} {error}
</div> </div>
)} )}
<a href="/api/auth/oidc/login" <a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
style={{ style={{
width: '100%', padding: '12px', width: '100%', padding: '12px',
background: '#111827', color: 'white', background: '#111827', color: 'white',
@@ -510,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 }}>
@@ -531,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'}
@@ -561,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' }}>
@@ -577,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' }}>
@@ -593,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' }}>
@@ -624,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('') }}
@@ -651,7 +720,7 @@ export default function LoginPage(): React.ReactElement {
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span> <span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} /> <div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div> </div>
<a href="/api/auth/oidc/login" <a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
style={{ style={{
marginTop: 12, width: '100%', padding: '12px', marginTop: 12, width: '100%', padding: '12px',
background: 'white', color: '#374151', background: 'white', color: '#374151',
+1 -1
View File
@@ -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
} }
+541 -10
View File
@@ -1,13 +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 } from '../api/client' import { authApi, adminApi } 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'
@@ -17,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' },
@@ -33,7 +44,7 @@ interface SectionProps {
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement { function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
return ( return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', breakInside: 'avoid', marginBottom: 24 }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} /> <Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2> <h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
@@ -45,17 +56,189 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
) )
} }
function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button onClick={onToggle}
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(() => {
authApi.getAppConfig?.().then((cfg: any) => {
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
}).catch(() => {})
}, [])
if (notifChannel === 'none') {
return (
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationsDisabled')}
</p>
)
}
const channelLabel = notifChannel === 'email'
? (t('admin.notifications.email') || 'Email (SMTP)')
: (t('admin.notifications.webhook') || 'Webhook')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
{t('settings.notificationsActive')}: {channelLabel}
</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
{t('settings.notificationsManagedByAdmin')}
</p>
</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>>({})
// Addon gating (derived from store)
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const [immichUrl, setImmichUrl] = useState('')
const [immichApiKey, setImmichApiKey] = useState('')
const [immichConnected, setImmichConnected] = useState(false)
const [immichTesting, setImmichTesting] = useState(false)
useEffect(() => {
loadAddons()
}, [])
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 () => {
setSaving(s => ({ ...s, immich: true }))
try {
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'))
const res = await apiClient.get('/integrations/immich/status')
setImmichConnected(res.data.connected)
setImmichTestPassed(false)
} catch {
toast.error(t('memories.connectionError'))
} finally {
setSaving(s => ({ ...s, immich: false }))
}
}
const handleTestImmich = async () => {
setImmichTesting(true)
try {
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
if (res.data.connected) {
toast.success(`${t('memories.connectionSuccess')}${res.data.user?.name || ''}`)
setImmichTestPassed(true)
} else {
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
setImmichTestPassed(false)
}
} catch {
toast.error(t('memories.connectionError'))
} finally {
setImmichTesting(false)
}
}
// 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)
@@ -85,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 || '')
@@ -166,18 +414,21 @@ export default function SettingsPage(): React.ReactElement {
<Navbar /> <Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6"> <div className="max-w-5xl mx-auto px-4 py-8">
<div> <style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
<div style={{ marginBottom: 24 }}>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1> <h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p> <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
</div> </div>
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
{/* Map settings */} {/* Map settings */}
<Section title={t('settings.map')} icon={Map}> <Section title={t('settings.map')} icon={Map}>
<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 => ({
@@ -385,8 +636,239 @@ export default function SettingsPage(): React.ReactElement {
))} ))}
</div> </div>
</div> </div>
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
</Section> </Section>
{/* Notifications */}
<Section title={t('settings.notifications')} icon={Lock}>
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
</Section>
{/* Immich — only when Memories addon is enabled */}
{memoriesEnabled && (
<Section title="Immich" icon={Camera}>
<div className="space-y-3">
<div>
<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); setImmichTestPassed(false) }}
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" />
</div>
<div>
<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); setImmichTestPassed(false) }}
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" />
</div>
<div className="flex items-center gap-3">
<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"
title={!immichTestPassed ? t('memories.testFirst') : ''}>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button onClick={handleTestImmich} disabled={immichTesting}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
{immichTesting
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />}
{t('memories.testConnection')}
</button>
{immichConnected && (
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')}
</span>
)}
</div>
</div>
</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>
@@ -444,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')))
} }
@@ -467,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>
@@ -524,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 {
@@ -581,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 {
@@ -594,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>
@@ -795,6 +1325,7 @@ export default function SettingsPage(): React.ReactElement {
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
</div> </div>
</div> </div>
+396
View File
@@ -0,0 +1,396 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import { getLocaleForLanguage } from '../i18n'
import { shareApi } from '../api/client'
import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function createMarkerIcon(place: any) {
const cat = place.category
const color = cat?.color || '#6366f1'
const CatIcon = getCategoryIcon(cat?.icon)
const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }))
return L.divIcon({
className: '',
iconSize: [28, 28],
iconAnchor: [14, 14],
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}</div>`,
})
}
function FitBoundsToPlaces({ places }: { places: any[] }) {
const map = useMap()
useEffect(() => {
if (places.length === 0) return
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
}, [places, map])
return null
}
export default function SharedTripPage() {
const { token } = useParams<{ token: string }>()
const { t, locale } = useTranslation()
const [data, setData] = useState<any>(null)
const [error, setError] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
if (!token) return
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
if (error) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔒</div>
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#111827' }}>{t('shared.expired')}</h1>
<p style={{ color: '#6b7280', marginTop: 8 }}>{t('shared.expiredHint')}</p>
</div>
</div>
)
if (!data) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
<div style={{ width: 32, height: 32, border: '3px solid #e5e7eb', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.6s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number)
// Map places
const mapPlaces = selectedDay
? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
: (places || []).filter((p: any) => p?.lat && p?.lng)
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary, #f3f4f6)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
{/* Cover image background */}
{trip.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${trip.cover_image.startsWith('http') ? trip.cover_image : trip.cover_image.startsWith('/') ? trip.cover_image : '/uploads/' + trip.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
{/* Background decoration */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
{/* Logo */}
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)' }}>
<img src="/icons/icon-white.svg" alt="TREK" width="26" height="26" />
</div>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12 }}>Travel Resource & Exploration Kit</div>
<h1 style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{trip.title}</h1>
{trip.description && (
<div style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{trip.description}</div>
)}
{(trip.start_date || trip.end_date) && (
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
</span>
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
</div>
)}
<div style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('shared.readOnly')}</div>
{/* Language picker - top right */}
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
<button onClick={() => setShowLangPicker(v => !v)} style={{
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
</button>
{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 }}>
{SUPPORTED_LANGUAGES.map(lang => (
<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' }}
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>{lang.label}</button>
))}
</div>
)}
</div>
</div>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, marginBottom: 20, overflowX: 'auto', padding: '2px 0' }}>
{[
{ id: 'plan', label: t('shared.tabPlan'), Icon: Map },
...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []),
...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []),
...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []),
...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
padding: '8px 18px', borderRadius: 12, border: '1.5px solid', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 6,
background: activeTab === tab.id ? '#111827' : 'var(--bg-card, white)',
borderColor: activeTab === tab.id ? '#111827' : 'var(--border-faint, #e5e7eb)',
color: activeTab === tab.id ? 'white' : '#6b7280',
boxShadow: activeTab === tab.id ? '0 2px 8px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.04)',
}}><tab.Icon size={13} /><span className="hidden sm:inline">{tab.label}</span></button>
))}
</div>
{/* Map */}
{activeTab === 'plan' && (<>
<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%' }}>
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" referrerPolicy="strict-origin-when-cross-origin" />
<FitBoundsToPlaces places={mapPlaces} />
{mapPlaces.map((p: any) => (
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
<Tooltip>{p.name}</Tooltip>
</Marker>
))}
</MapContainer>
</div>
{/* Day Plan */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{sortedDays.map((day: any, di: number) => {
const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || [])
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
].sort((a, b) => a.k - b.k)
return (
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
<div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)}
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
</div>
{dayAccs.map((acc: any) => (
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
<Hotel size={8} /> {acc.place_name}
</span>
))}
<span style={{ fontSize: 11, color: '#9ca3af' }}>{da.length} {t('shared.places')}</span>
</div>
{selectedDay === day.id && merged.length > 0 && (
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{merged.map((item: any, idx: number) => {
if (item.type === 'transport') {
const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
let sub = ''
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
return (
<div key={`t-${r.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)' }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'rgba(59,130,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<TIcon size={12} color="#3b82f6" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, color: '#111827' }}>{r.title}{time ? ` · ${time}` : ''}</div>
{sub && <div style={{ fontSize: 10, color: '#6b7280' }}>{sub}</div>}
</div>
</div>
)
}
if (item.type === 'note') {
return (
<div key={`n-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, background: '#f9fafb', border: '1px solid #f3f4f6' }}>
<FileText size={12} color="#9ca3af" />
<div>
<div style={{ fontSize: 12, color: '#374151' }}>{item.data.text}</div>
{item.data.time && <div style={{ fontSize: 10, color: '#9ca3af' }}>{item.data.time}</div>}
</div>
</div>
)
}
const place = item.data.place
if (!place) return null
const cat = categories?.find((c: any) => c.id === place.category_id)
return (
<div key={`p-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px', borderRadius: 6 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: cat?.color || '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{place.image_url ? <img src={place.image_url} style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} /> : <MapPin size={13} color="white" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#111827' }}>{place.name}</div>
{(place.address || place.description) && <div style={{ fontSize: 10, color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{place.address || place.description}</div>}
</div>
{place.place_time && <span style={{ fontSize: 10, color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}><Clock size={9} />{place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</>)}
{/* Bookings */}
{activeTab === 'bookings' && (reservations || []).length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(reservations || []).map((r: any) => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<TIcon size={15} color="#6b7280" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{r.title}</div>
<div style={{ fontSize: 11, color: '#9ca3af', display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 2 }}>
{date && <span>{date}</span>}
{time && <span>{time}</span>}
{r.location && <span>{r.location}</span>}
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
{meta.train_number && <span>{meta.train_number}</span>}
</div>
</div>
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 20, fontWeight: 600, background: r.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)', color: r.status === 'confirmed' ? '#16a34a' : '#d97706' }}>
{r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}
</span>
</div>
)
})}
</div>
)}
{/* Packing */}
{activeTab === 'packing' && (packing || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
{Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
<div key={cat}>
<div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div>
{items.map((item: any) => (
<div key={item.id} style={{ padding: '6px 16px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid #f9fafb' }}>
<span style={{ fontSize: 13, color: item.checked ? '#9ca3af' : '#111827', textDecoration: item.checked ? 'line-through' : 'none' }}>{item.name}</span>
</div>
))}
</div>
))}
</div>
)}
{/* Budget */}
{activeTab === 'budget' && (budget || []).length > 0 && (() => {
const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Total card */}
<div style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px', color: 'white' }}>
<div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div>
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}</div>
</div>
{/* By category */}
{Object.entries(grouped).map(([cat, items]: [string, any]) => (
<div key={cat} style={{ background: 'var(--bg-card, white)', borderRadius: 12, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span>
</div>
{items.map((item: any) => (
<div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}>
<span style={{ fontSize: 13, color: '#111827' }}>{item.name}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}</span>
</div>
))}
</div>
))}
</div>
)
})()}
{/* Collab Chat */}
{activeTab === 'collab' && (collab || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageCircle size={14} color="#6b7280" />
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span>
</div>
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{(collab || []).map((msg: any, i: number) => {
const prevMsg = i > 0 ? collab[i - 1] : null
const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString()
return (
<div key={msg.id}>
{showDate && (
<div style={{ textAlign: 'center', margin: '8px 0', fontSize: 10, fontWeight: 600, color: '#9ca3af' }}>
{new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: '#6b7280', flexShrink: 0, overflow: 'hidden' }}>
{msg.avatar ? <img src={`/uploads/avatars/${msg.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (msg.username || '?')[0].toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{msg.username}</span>
<span style={{ fontSize: 10, color: '#9ca3af' }}>{new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 3, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>{msg.text}</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Footer */}
<div style={{ textAlign: 'center', padding: '40px 0 20px' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'var(--bg-card, white)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} />
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: '#d1d5db' }}>Made with <span style={{ color: '#ef4444' }}>&hearts;</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a></div>
</div>
</div>
</div>
)
}
+221 -53
View File
@@ -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'
@@ -12,6 +14,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal' import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal' import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal' import { ReservationModal } from '../components/Planner/ReservationModal'
import MemoriesPanel from '../components/Memories/MemoriesPanel'
import ReservationsPanel from '../components/Planner/ReservationsPanel' import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel' import PackingListPanel from '../components/Packing/PackingListPanel'
import FileManager from '../components/Files/FileManager' import FileManager from '../components/Files/FileManager'
@@ -21,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'
@@ -35,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[]>([])
@@ -46,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])
@@ -54,7 +70,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
addonsApi.enabled().then(data => { addonsApi.enabled().then(data => {
const map = {} const map = {}
data.addons.forEach(a => { map[a.id] = true }) data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
}).catch(() => {}) }).catch(() => {})
authApi.getAppConfig().then(config => { authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -67,6 +83,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
] ]
@@ -78,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()
@@ -96,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
@@ -111,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) {
@@ -156,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 })
@@ -177,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) {
@@ -189,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)
@@ -214,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]))
@@ -249,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') {
@@ -267,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
@@ -281,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(() => {})
@@ -318,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>
) )
@@ -438,12 +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')}
onExpandedDaysChange={setExpandedDayIds}
/> />
{!leftCollapsed && ( {!leftCollapsed && (
<div <div
@@ -492,6 +598,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} )}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}> <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
<PlacesSidebar <PlacesSidebar
tripId={tripId}
places={places} places={places}
categories={categories} categories={categories}
assignments={assignments} assignments={assignments}
@@ -540,11 +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={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
/> />
) )
})()} })()}
{selectedPlace && ( {selectedPlace && !isMobile && (
<PlaceInspector <PlaceInspector
place={selectedPlace} place={selectedPlace}
categories={categories} categories={categories}
@@ -555,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
@@ -570,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 {
@@ -585,10 +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={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
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()}>
@@ -600,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} /> ? <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 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>
@@ -643,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}
@@ -656,6 +818,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
)} )}
{activeTab === 'memories' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
</div>
)}
{activeTab === 'collab' && ( {activeTab === 'collab' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}> <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} /> <CollabPanel tripId={tripId} tripMembers={tripMembers} />
@@ -663,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)}
+128
View File
@@ -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
}
+35
View File
@@ -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)
},
}))
+45 -35
View File
@@ -17,18 +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
/** 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>
@@ -36,17 +40,22 @@ interface AuthState {
deleteAvatar: () => Promise<void> deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void setHasMapsKey: (val: boolean) => 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,
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 })
@@ -56,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')
@@ -77,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')
@@ -98,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')
@@ -117,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({
@@ -140,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 })
}
} }
}, },
@@ -201,21 +210,22 @@ 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 }),
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')
+52
View File
@@ -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
}
}
}
+30 -18
View File
@@ -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'))
} }
@@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
files: state.files.filter(f => f.id !== payload.fileId), files: state.files.filter(f => f.id !== payload.fileId),
} }
// Memories / Photos
case 'memories:updated':
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
return {}
default: default:
return {} return {}
} }
+21 -4
View File
@@ -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 {
@@ -118,15 +123,22 @@ export interface Reservation {
trip_id: number trip_id: number
name: string name: string
title?: string title?: string
type: string | null type: string
status: 'pending' | 'confirmed' status: 'pending' | 'confirmed'
date: string | null date: string | null
time: string | null time: string | null
reservation_time?: string | null
reservation_end_time?: string | null
location?: string | null
confirmation_number: string | null confirmation_number: string | null
notes: string | null notes: string | null
url: string | null url: string | null
day_id?: number | null
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null accommodation_id?: number | null
metadata?: Record<string, string> | null day_plan_position?: number | null
metadata?: Record<string, string> | string | null
created_at: string created_at: string
} }
@@ -148,6 +160,7 @@ export interface TripFile {
deleted_at?: string | null deleted_at?: string | null
created_at: string created_at: string
reservation_title?: string reservation_title?: string
linked_reservation_ids?: number[]
url?: string url?: string
} }
@@ -163,6 +176,7 @@ export interface Settings {
time_format: string time_format: string
show_place_description: boolean show_place_description: boolean
route_calculation?: boolean route_calculation?: boolean
blur_booking_codes?: boolean
} }
export interface AssignmentsMap { export interface AssignmentsMap {
@@ -271,6 +285,9 @@ export interface AppConfig {
oidc_display_name?: string oidc_display_name?: string
has_maps_key?: boolean has_maps_key?: boolean
allowed_file_types?: string allowed_file_types?: 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
@@ -361,7 +378,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
// MergedItem used in day notes hook // MergedItem used in day notes hook
export interface MergedItem { export interface MergedItem {
type: 'assignment' | 'note' type: 'assignment' | 'note' | 'place' | 'transport'
sortKey: number sortKey: number
data: Assignment | DayNote data: Assignment | DayNote | Reservation
} }
+5 -3
View File
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2 return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
} }
export function formatDate(dateStr: string | null | undefined, locale: string): string | null { export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short', weekday: 'short', day: 'numeric', month: 'short',
}) }
if (timeZone) opts.timeZone = timeZone
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
} }
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string { export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
+16 -7
View File
@@ -8,9 +8,10 @@ export default defineConfig({
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
workbox: { workbox: {
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)
@@ -44,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] },
}, },
}, },
], ],
@@ -86,6 +88,9 @@ export default defineConfig({
}, },
}), }),
], ],
build: {
sourcemap: false,
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
@@ -100,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,
} }
} }
} }
+23 -2
View File
@@ -2,13 +2,34 @@ 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
- JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000 - PORT=3000
- 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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+27 -3
View File
@@ -1,3 +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.)
# 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
+698 -3
View File
@@ -1,16 +1,18 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.6.2", "version": "2.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.6.2", "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",
@@ -19,24 +21,28 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"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",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11", "@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
@@ -460,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",
@@ -558,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",
@@ -653,6 +1021,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": { "node_modules/@types/qrcode": {
"version": "1.5.6", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@@ -760,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",
@@ -1314,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",
@@ -1368,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",
@@ -1643,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",
@@ -1698,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",
@@ -2006,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",
@@ -2088,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",
@@ -2152,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",
@@ -2520,6 +3076,15 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -2690,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",
@@ -2709,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",
@@ -2924,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",
@@ -2939,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",
@@ -3034,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",
@@ -3514,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",
@@ -3615,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"
}
} }
} }
} }
+9 -3
View File
@@ -1,15 +1,17 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.7.0", "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",
@@ -17,25 +19,29 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.1.1", "multer": "^2.1.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"node-fetch": "^2.7.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"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",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11", "@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
File diff suppressed because one or more lines are too long

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