Compare commits

...

212 Commits

Author SHA1 Message Date
github-actions[bot] d8367ec878 chore: bump version to 2.8.3 [skip ci] 2026-04-04 12:54:00 +00:00
jubnl 79057327fa Merge remote-tracking branch 'origin/main' 2026-04-04 14:53:40 +02:00
jubnl 0943184b1e test(trips): update TRIP-002 to reflect 7-day default window behavior 2026-04-04 14:53:12 +02:00
github-actions[bot] a4752ae692 chore: bump version to 2.8.2 [skip ci] 2026-04-04 12:48:36 +00:00
jubnl e6068d44b0 docs(oidc): fix OIDC_SCOPE default and clarify override behavior, skip CI for docs-only pushes, remove stale audit files 2026-04-04 14:48:11 +02:00
github-actions[bot] 5d3a740791 chore: bump version to 2.8.1 [skip ci] 2026-04-04 10:53:29 +00:00
mauriceboe 2c1c77f367 fix(oidc): revert default scope to 'openid email profile'
Removes 'groups' from the default OIDC_SCOPE fallback, which caused
invalid_scope errors with providers that don't support it (e.g. Google).

Fixes #391
2026-04-04 12:53:12 +02:00
github-actions[bot] 80d013dd19 chore: bump version to 2.8.0 [skip ci] 2026-04-03 22:35:37 +00:00
jubnl 929105f0e4 Merge remote-tracking branch 'origin/dev' into dev 2026-04-03 23:59:06 +02:00
jubnl 93c0d6fe78 fix(trips): default to 7-day window when dates are omitted on creation
- No dates → tomorrow to tomorrow+7d
- Start only → end = start+7d
- End only → start = end-7d
- Both provided → unchanged

fix(ci): include client/package-lock.json in version bump commit
2026-04-03 23:58:39 +02:00
Maurice 88a40c3294 docs: update Discord channel to #github-pr 2026-04-03 23:53:12 +02:00
Maurice c056401000 ci: auto version bump on main — minor for dev merges, patch for hotfixes 2026-04-03 23:44:11 +02:00
jubnl eae799c7d6 fix(deployment): remove unessessary files from docker image 2026-04-03 23:07:00 +02:00
Maurice 20ce7460c1 docs: add contributing guidelines 2026-04-03 22:59:28 +02:00
jubnl d765a80ea3 fix(immich): proxy shared photos using owner's Immich credentials
Trip members viewing another member's shared photo were getting a 404
because the proxy endpoints always used the requesting user's Immich
credentials instead of the photo owner's. The ?userId= query param the
client already sent was silently ignored.

- Add canAccessUserPhoto() to verify the asset is shared and the
  requesting user is a trip member before allowing cross-user proxying
- Pass optional ownerUserId through proxyThumbnail, proxyOriginal, and
  getAssetInfo so credentials are fetched for the correct user
- Enforce shared=1 check so unshared photos remain inaccessible
2026-04-03 22:32:41 +02:00
jubnl 1dc189b466 New issue template and workflow 2026-04-03 21:51:03 +02:00
jubnl e624ee337f update environment variables for unraid template 2026-04-03 21:48:27 +02:00
Maurice 6ba5df0215 fix(immich): replace ephemeral token auth with blob fetch for Safari compatibility (#381)
Safari blocks SameSite=Lax cookies on <img> subresource requests,
causing 401 errors when loading Immich thumbnails and originals.

Replaced the token-based <img src> approach with direct fetch()
using credentials: 'include', which reliably sends cookies across
all browsers. Images are now loaded as blobs with ObjectURLs.

Added a concurrency limiter (max 6 parallel fetches) to prevent
ERR_INSUFFICIENT_RESOURCES when many photos load simultaneously.
Queue is cleared when the photo picker closes so gallery images
load immediately.
2026-04-03 21:41:05 +02:00
Maurice 897e1bff26 fix(dates): use UTC parsing and display for date-only strings (#351)
Date-only strings parsed with new Date(dateStr + 'T00:00:00') were
interpreted relative to the local timezone, causing off-by-one day
display for users west of UTC. Fixed across 16 files by parsing as
UTC ('T00:00:00Z') and displaying with timeZone: 'UTC'.
2026-04-03 21:18:56 +02:00
Julien G. ba14636c1d Merge pull request #376 from darioackermann/dac/helm-checksums
chore(helm): add config/secret checksum to deployment
2026-04-03 19:56:26 +02:00
jubnl 6c72295424 fix(vacay): fix entitlement counter, year deletion, and year creation bugs
- toggleCompanyHoliday now calls loadStats() so the entitlement sidebar
  updates immediately when a vacation day is converted to a company holiday
- deleteYear now deletes vacay_user_years rows for the removed year,
  preventing stale entitlement data from persisting and re-appearing
  when the year is re-created
- deleteYear recalculates carry-over for year+1 when year N is deleted,
  using the new actual previous year as the source
- removeYear store action now calls loadStats() so the sidebar reflects
  the recalculated carry-over without requiring a page refresh
- Add prev-year button (+[<] 2026 [>]+) so users can add years going
  backwards after deleting a past year; add vacay.addPrevYear i18n key
  to all 13 supported languages

Closes #371
2026-04-03 19:51:22 +02:00
jubnl f6faaa23b0 fix(vacay): reset selectedYear when the active year is deleted
When deleting the currently selected year, selectedYear was never
cleared, leaving the deleted year shown as active in the UI. Now
resets to the latest remaining year, or the current calendar year
if all years have been removed.

Fixes #369
2026-04-03 19:24:49 +02:00
jubnl 98813a9b40 fix(helm): add ingressClassName support to Helm chart
Adds `ingress.className` value and renders `ingressClassName` in the
Ingress spec, allowing users to specify the ingress controller class.
Closes #377.
2026-04-03 19:15:51 +02:00
jubnl e0105115f4 fix(immich): detect http→https redirect on test connection and update URL
When a user enters an http:// Immich URL that redirects to https://,
the test succeeded (GET follows redirects fine) but subsequent POST
requests (e.g. photo search) broke due to method downgrade on 301/302.

Now testConnection() checks resp.url against the input URL after a
successful fetch. If the only difference is http→https on the same
host and port, it returns a canonicalUrl so the frontend can update
the input field before the user saves — ensuring the correct URL is
stored.
2026-04-03 19:12:55 +02:00
Dario Ackermann 217458da81 chore(helm): add config/secret checksum to deployment 2026-04-03 17:34:13 +02:00
jubnl 8dd22ab8a3 fix: deselect day when closing DayDetailPanel
Closing the panel via the X button now calls handleSelectDay(null),
clearing selectedDayId from the Zustand store and resetting the route.
Fixes #356.
2026-04-03 17:04:45 +02:00
jubnl cfdbf9235f feat(helm): add all missing env vars from README to Helm chart
Add TZ, LOG_LEVEL, FORCE_HTTPS, TRUST_PROXY, OIDC_ISSUER, OIDC_CLIENT_ID,
OIDC_DISPLAY_NAME, OIDC_ONLY, OIDC_ADMIN_CLAIM, OIDC_ADMIN_VALUE, OIDC_SCOPE,
DEMO_MODE to values.yaml and configmap.yaml. Add OIDC_CLIENT_SECRET as a
secretEnv entry rendered in secret.yaml and mounted in deployment.yaml.
2026-04-03 16:15:18 +02:00
jubnl 059158d087 add feature request bad names as exclusion 2026-04-03 16:12:01 +02:00
jubnl 77393ff40b auto close issue on empty/bad title 2026-04-03 16:01:12 +02:00
jubnl 64d4a20403 feat: add MCP_RATE_LIMIT env variable to control MCP request rate
Document MCP_RATE_LIMIT in README, docker-compose, .env.example, Helm values and configmap.
2026-04-03 15:44:33 +02:00
jubnl 6b94c0632c feat: add about section in user setting with trek version + discord link 2026-04-03 15:30:10 +02:00
Maurice cb124ba3ec fix: show required indicator on day note title, disable save when empty 2026-04-03 15:24:13 +02:00
Maurice ba01b4acac fix: mobile day detail opens on single tap instead of double-click (#311) 2026-04-03 14:55:44 +02:00
jubnl ce72f45d9a Merge remote-tracking branch 'origin/dev' into dev 2026-04-03 14:45:34 +02:00
jubnl bf2eea18c3 Fix: add bypass for ssrf check to force dissallow internal ip 2026-04-03 14:45:12 +02:00
Maurice 501bab0f69 test: update cookie test to match sameSite lax change 2026-04-03 14:42:48 +02:00
Maurice 5dd80d5cb8 feat: Discord links, translation sync, iOS login fix, trip copy fix
- Add Discord button to admin GitHub panel and user menu
- Sync all 13 translation files to 1434 keys with native translations
- Fix duplicate keys in Polish translation (pl.ts)
- Fix iOS login race condition: sameSite strict→lax, loadUser sequence counter
- Fix trip copy route: add missing db, Trip, TRIP_SELECT imports
2026-04-03 14:39:44 +02:00
Julien G. 8f6de3cd23 Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-03 14:25:36 +02:00
Julien G. 816696d0fe Merge pull request #349 from mauriceboe/343-bug-attachments-in-collab-notes-seem-to-be-broken
fix: collab note attachments broken (#343)
2026-04-03 14:14:42 +02:00
jubnl bb54fda6dc fix: collab note attachments broken (#343)
- Fix attachment URLs to use /api/trips/:id/files/:id/download instead
  of /uploads/files/... which was unconditionally blocked with 401
- Use getAuthUrl() with ephemeral tokens for displaying attachments and
  opening them in a new tab (images, PDFs, documents)
- Replace htmlFor/id label pattern with ref.current.click() for the
  file picker button in NoteFormModal — fixes file not being added to
  pending list on first note creation
- Add integration tests COLLAB-028 to COLLAB-031 covering URL format,
  listing URLs, ephemeral token download, and unauthenticated 401
2026-04-03 14:11:18 +02:00
marco783 36f2292f2d added map preview to settings, change latitude and longitude with left click on the map (#348) 2026-04-03 13:21:47 +02:00
Julien G. 905c7d460b Add comprehensive backend test suite (#339)
* add test suite, mostly covers integration testing, tests are only backend side

* workflow runs the correct script

* workflow runs the correct script

* workflow runs the correct script

* unit tests incoming

* Fix multer silent rejections and error handler info leak

- Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts,
  and files.ts so invalid uploads return an error instead of silently
  dropping the file
- Error handler in app.ts now always returns 500 / "Internal server
  error" instead of forwarding err.message to the client

* Use statusCode consistently for multer errors and error handler

- Error handler in app.ts reads err.statusCode to forward the correct
  HTTP status while keeping the response body generic
2026-04-03 13:17:53 +02:00
Gérnyi Márk d48714d17a feat: add copy/duplicate trip from dashboard (#270)
New POST /api/trips/:id/copy endpoint that deep copies all trip
planning data (days, places, assignments, reservations, budget,
packing, accommodations, day notes) with proper FK remapping
inside a transaction. Skips files, collab data, and members.

Copy button on all dashboard card types (spotlight, grid, list,
archived) gated by trip_create permission. Translations for all
12 languages.

Also adds reminder_days to Trip interface (removes as-any casts).
2026-04-03 12:38:45 +02:00
Wojciech Chrzan a0db42fbfe feat(i18n): add Polish language support (#252) 2026-04-03 12:28:48 +02:00
Julien G. f4d0ccb454 Merge pull request #344 from marco783/addPeopleCount
added trip member count to dashboard
2026-04-03 11:23:10 +02:00
Marco Pasquali a40983e65e added trip member count to dashboard
added translations for  (generated with AI, so they could be wrong)
2026-04-03 11:10:21 +02:00
jubnl f32c103fe1 fix: deleted chats are not shown in share view anymore 2026-04-03 10:50:34 +02:00
Julien G. 0b77fe5292 Merge pull request #277 from Cod3d1PA/feat/holiday-hover-tooltip
feat: show holiday name on hover in calendar
2026-04-03 04:27:39 +02:00
jubnl 9afb51fcc0 fix: ensure invite link shows the register page. Closes #335 2026-04-03 03:58:44 +02:00
jubnl 4e10028669 document APP_URL usage 2026-04-03 03:51:29 +02:00
jubnl d4e16ebe49 fix: use APP_URL is defined as base url in mails 2026-04-03 03:44:45 +02:00
Julien G. 4ff03a1f2c Merge pull request #330 from jubnl/dev
rename import
2026-04-02 19:48:39 +02:00
jubnl 40f7c00adb rename import 2026-04-02 19:47:50 +02:00
Julien G. b43d8d119f Merge pull request #329 from jubnl/dev
feat: in-app notification system
2026-04-02 19:37:27 +02:00
jubnl 74e3f85866 fix: finish rename refactor 2026-04-02 19:09:43 +02:00
jubnl bbf3f0cae8 fix: update import paths after client-side file renames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:59:22 +02:00
jubnl c0e9a771d6 feat: add in-app notification system with real-time delivery
Introduces a full in-app notification system with three types (simple,
boolean with server-side callbacks, navigate), three scopes (user, trip,
admin), fan-out persistence per recipient, and real-time push via
WebSocket. Includes a notification bell in the navbar, dropdown, dedicated
/notifications page, and a dev-only admin tab for testing all notification
variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:57:52 +02:00
Maurice c49272efc1 add Discord community badge to README 2026-04-02 17:19:24 +02:00
Maurice 979322025d refactor: extract business logic from routes into reusable service modules 2026-04-02 17:14:53 +02:00
Maurice f0131632a7 fix: show icon-only trip tabs on mobile to prevent overflow 2026-04-02 15:05:36 +02:00
Maurice ffe91604b5 Merge pull request #273 from lucaam/undo_button_v2
feat: undo button for trip planner (+ fix to route preview)
2026-04-02 14:59:16 +02:00
Maurice e7fa8f5da9 fix: widen budget sidebar from 180px to 240px to prevent clipping 2026-04-02 14:55:10 +02:00
Maurice 3256f5156d fix: photo marker badge now renders above circle instead of clipped inside 2026-04-02 14:50:08 +02:00
Maurice d45073a0bd Merge pull request #298 from jubnl/dev
feat: Adds 2 environment variables to control initial admin user credentials, adds 1 environment variable to control OIDC scope
2026-04-02 14:34:28 +02:00
jubnl a4d6348a79 fix: add raw.githubusercontent.com to CSP connect-src for Atlas map
The Atlas feature fetches country GeoJSON from GitHub raw content, which
was blocked by the Content Security Policy connect-src directive.

Closes #285
2026-04-02 14:10:14 +02:00
jubnl c944a7d101 fix: allow unauthenticated access to public share links
Skip loadUser() and exclude /shared/ from the 401 redirect interceptor
so unauthenticated users can open shared trip links without being
redirected to /login. Fixes #308.
2026-04-02 14:05:38 +02:00
jubnl 45e0c7e546 fix: replace toast.warn with toast.warning in Immich save handler
toast.warn does not exist in the toast library; calling it threw an error
that was caught and displayed as "Could not connect to Immich" even when
the save succeeded. Fixes #309.
2026-04-02 13:59:08 +02:00
jubnl 32b63adc68 fix: add OIDC_SCOPE env var and document it across all config files
Fixes #306 — OIDC scopes were hardcoded to 'openid email profile',
causing OIDC_ADMIN_CLAIM-based role mapping to fail when the required
scope (e.g. 'groups') wasn't requested. The new OIDC_SCOPE variable
defaults to 'openid email profile groups' so group-based admin mapping
works out of the box. Variable is now documented in README, docker-compose,
.env.example, and the Helm chart values.
2026-04-02 07:46:58 +02:00
jubnl b1cca15f6f docs: add ADMIN_EMAIL and ADMIN_PASSWORD to README env vars table and compose snippet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:22:18 +02:00
jubnl dfeb7b3db7 Merge remote-tracking branch 'fork/dev'
merge
2026-04-01 23:14:15 +02:00
jubnl 50424fc574 feat: support ADMIN_EMAIL and ADMIN_PASSWORD env vars for initial admin setup
Allow the first-boot admin account to be configured via ADMIN_EMAIL and
ADMIN_PASSWORD environment variables. If both are set the account is created
with those credentials; otherwise the existing random-password fallback is
used. Documented across .env.example, docker-compose.yml, Helm chart
(values.yaml, secret.yaml, deployment.yaml), and CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:09:57 +02:00
Julien G. 12a910876e Merge pull request #1 from jubnl/main
apply hot fixes to dev
2026-04-01 23:07:38 +02:00
Maurice d73a5e223c Merge pull request #292 from jubnl/main 2026-04-01 21:52:26 +02:00
jubnl fd9567e3fe Merge remote-tracking branch 'fork/main' 2026-04-01 21:44:56 +02:00
jubnl ae04071466 docs: document COOKIE_SECURE and OIDC_DISCOVERY_URL across all config files
Adds COOKIE_SECURE (fixes login loop on plain-HTTP setups) and the previously
undocumented OIDC_DISCOVERY_URL to .env.example, docker-compose.yml, README.md,
chart/values.yaml, chart/templates/configmap.yaml, and chart/README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:44:02 +02:00
Maurice 2ab3f59722 Merge pull request #290 from jubnl/main 2026-04-01 21:42:50 +02:00
Julien G. 7257fac859 Merge branch 'mauriceboe:main' into main 2026-04-01 21:20:50 +02:00
jubnl 1a4c04e239 fix: resolve Immich 401 passthrough causing spurious login redirects
- Auth middleware now tags its 401s with code: AUTH_REQUIRED so the
  client interceptor only redirects to /login on genuine session failures,
  not on upstream API errors
- Fix /albums and album sync routes using raw encrypted API key instead
  of getImmichCredentials() (which decrypts it), causing Immich to reject
  requests with 401
- Add toast error notifications for all Immich operations in MemoriesPanel
  that previously swallowed errors silently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:19:53 +02:00
Maurice 39a495714f Merge pull request #284 from jubnl/main 2026-04-01 20:43:37 +02:00
jubnl fabf5a7e26 fix: remove redundant db import alias in index.ts
db was already imported as addonDb; the extra db named import was
unnecessary. Updated the one stray db.prepare call at line 155 to use
addonDb consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:38:25 +02:00
jubnl e71bd6768e fix: show actual backend error messages on login page and add missing db import
- LoginPage now uses getApiErrorMessage() instead of err.message so
  backend validation errors (e.g. "Password must be at least 8 characters")
  are displayed instead of the generic "Request failed with status code 400"
- Add missing db import in server/src/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:37:01 +02:00
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
Cod3d1PA 505bf04a1f feat: show holiday name on hover in calendar
Add a native title tooltip to calendar day cells so hovering over a
public holiday reveals its name (and custom label if configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:01:15 +00:00
Luca 41bfcf2f76 fix: stale closure in updateRouteForDay causes route to disappear on place click
useCallback captured tripStore at creation time (dep: [routeCalcEnabled]).
If assignments were empty on first render (trip still loading), the callback
would permanently see empty assignments and call setRoute(null) whenever
invoked — e.g. when clicking a place triggers onSelectDay → updateRouteForDay.

Fix: store tripStore in a ref updated on every render so the callback always
reads the latest assignments without needing to be recreated.
2026-04-01 18:29:40 +02:00
Luca e308204808 feat: undo button for trip planner
Implements a full undo history system for the Plan screen.

New hook: usePlannerHistory (client/src/hooks/usePlannerHistory.ts)
- Maintains a LIFO stack (up to 30 entries) of reversible actions
- Exposes pushUndo(label, fn), undo(), canUndo, lastActionLabel

Tracked actions:
- Assign place to day (undo: remove the assignment)
- Remove place from day (undo: re-assign at original position)
- Reorder places within a day (undo: restore previous order)
- Move place to a different day (undo: move back)
- Optimize route (undo: restore original order)
- Lock / unlock place (undo: toggle back)
- Delete place (undo: recreate place + restore all day assignments)
- Add place (undo: delete it)
- Import from GPX (undo: delete all imported places)
- Import from Google Maps list (undo: delete all imported places)

UI: Undo button (Undo2 icon) in DayPlanSidebar header. PDF, ICS and
Undo buttons all use custom instant hover tooltips instead of native
title attributes.

A toast notification confirms each undo action.

Translations: undo.* keys added to all 12 language files.
2026-04-01 18:20:14 +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
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
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
261 changed files with 50895 additions and 19530 deletions
+23 -1
View File
@@ -5,6 +5,28 @@ client/dist
data
uploads
.git
.env
.github
**/.env
**/.env.*
*.log
*.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
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+108
View File
@@ -0,0 +1,108 @@
name: Bug Report
description: Create a report to help us improve TREK
title: "[BUG] "
labels: []
body:
- type: checkboxes
id: preflight
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- type: input
id: version
attributes:
label: TREK version
description: Found in the Settings → About, or in the Docker image tag
placeholder: "e.g. 2.8.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: When I do X, Y happens instead of Z…
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to reliably trigger the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment method
options:
- Docker Compose
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Sources
- Other
validations:
required: true
- type: input
id: os
attributes:
label: Host OS
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
- type: dropdown
id: user_os
attributes:
label: Accessing TREK from
options:
- Desktop browser
- Mobile browser
- Mobile app (PWA)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
- type: textarea
id: logs
attributes:
label: Relevant logs or error output
description: Paste any relevant server or browser console output here.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Drag and drop screenshots here if applicable.
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might help us understand the issue.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,67 @@
name: Close untitled issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-title:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Close if title is empty or generic
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const badTitles = [
"[bug]",
"bug report",
"bug",
"issue",
];
const featureRequestTitles = [
"feature request",
"[feature]",
"[feature request]",
"[enhancement]"
]
const titleLower = title.toLowerCase();
if (badTitles.includes(titleLower)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
});
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
});
}
+69 -7
View File
@@ -3,11 +3,72 @@ name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
permissions:
contents: write
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
run: |
# Check if this push is a merge commit from dev branch
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
BUMP="minor"
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
BUMP="minor"
else
BUMP="patch"
fi
echo "Bump type: $BUMP"
# Read current version
CURRENT=$(node -p "require('./server/package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
if [ "$BUMP" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)"
# Update both package.json files
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
@@ -21,6 +82,8 @@ jobs:
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: main
- uses: docker/setup-buildx-action@v3
@@ -54,13 +117,11 @@ jobs:
merge:
runs-on: ubuntu-latest
needs: build
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: version
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
with:
ref: main
- name: Download build digests
uses: actions/download-artifact@v4
@@ -79,12 +140,13 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION=${{ needs.version-bump.outputs.version }}
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/trek:$VERSION \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/nomad:$VERSION \
"${digests[@]}"
- name: Inspect manifest
+44
View File
@@ -0,0 +1,44 @@
name: Tests
permissions:
contents: read
on:
push:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
pull_request:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
jobs:
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: cd server && npm ci
- name: Run tests
run: cd server && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: coverage
path: server/coverage/
retention-days: 7
+6
View File
@@ -11,6 +11,9 @@ client/public/icons/*.png
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
# User data
server/data/
@@ -28,6 +31,7 @@ Thumbs.db
# IDE
.vscode/
.idea/
.claude/
# Logs
logs
@@ -52,3 +56,5 @@ coverage
.cache
*.tsbuildinfo
*.tgz
.scannerwork
+57
View File
@@ -0,0 +1,57 @@
# Contributing to TREK
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
## Pull Requests
### Your PR should include:
- **Summary** — What does this change and why? (1-3 bullet points)
- **Test plan** — How did you verify it works?
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
### Your PR will be closed if it:
- Wasn't discussed and approved in `#github-pr` on Discord first
- Introduces breaking changes
- Adds unnecessary complexity or features beyond scope
- Reformats or refactors unrelated code
- Adds dependencies without clear justification
### Commit messages
Use [conventional commits](https://www.conventionalcommits.org/):
```
fix(maps): correct zoom level on Safari
feat(budget): add CSV export for expenses
```
## Development Setup
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
# Server
cd server && npm install && npm run dev
# Client (separate terminal)
cd client && npm install && npm run dev
```
Server: `http://localhost:3001` | Client: `http://localhost:5173`
On first run, check the server logs for the auto-generated admin credentials.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+12 -17
View File
@@ -1,4 +1,4 @@
# Stage 1: React Client bauen
# Stage 1: Build React client
FROM node:22-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
@@ -6,37 +6,32 @@ RUN npm ci
COPY client/ ./
RUN npm run build
# Stage 2: Produktions-Server
# Stage 2: Production server
FROM node:22-alpine
WORKDIR /app
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
# Timezone support + native deps (better-sqlite3 needs build tools)
COPY server/package*.json ./
RUN apk add --no-cache tzdata python3 make g++ && \
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
apk del python3 make g++
# Server-Code kopieren
COPY server/ ./
# Gebauten Client kopieren
COPY --from=client-builder /app/client/dist ./public
# Fonts für PDF-Export kopieren
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 /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
RUN mkdir -p /app/data/logs /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 && \
chown -R node:node /app
RUN chown -R node:node /app
USER node
# Umgebung setzen
ENV NODE_ENV=production
ENV PORT=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)
+81 -14
View File
@@ -9,6 +9,7 @@
</p>
<p align="center">
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -98,7 +99,9 @@
## Quick Start
```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.
@@ -120,20 +123,54 @@ services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME="SSO"
# - OIDC_ONLY=true # disable password auth entirely
- 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
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - 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 to true to 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_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
```bash
@@ -162,6 +199,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.
### 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)
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
@@ -226,17 +275,34 @@ trek.yourdomain.com {
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment | `production` |
| `JWT_SECRET` | JWT signing secret | Auto-generated |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
| `OIDC_ISSUER` | OIDC provider URL | — |
| `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` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
| `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` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
| `OIDC_ONLY` | Disable password auth | `false` |
| `TRUST_PROXY` | Trust proxy headers | `1` |
| `DEMO_MODE` | Enable demo mode | `false` |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
| **Initial Setup** | | |
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
## Optional API Keys
@@ -261,6 +327,7 @@ docker build -t trek .
- **Database**: SQLite, stored in `./data/travel.db`
- **Uploads**: Stored in `./uploads/`
- **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups**: Create and restore via Admin Panel
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
+5 -2
View File
@@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app.
```sh
helm install trek ./chart \
--set secretEnv.JWT_SECRET=your_jwt_secret \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
@@ -29,5 +28,9 @@ See `values.yaml` for more options.
## 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 must be set for production use.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- 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.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+20 -10
View File
@@ -1,13 +1,23 @@
1. JWT_SECRET handling:
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
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. Example usage:
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
- Generate a random secret: `--set generateJwtSecret=true`
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 in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
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.
+51
View File
@@ -7,6 +7,57 @@ metadata:
data:
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
PORT: {{ .Values.env.PORT | quote }}
{{- if .Values.env.TZ }}
TZ: {{ .Values.env.TZ | quote }}
{{- end }}
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
{{- if .Values.env.APP_URL }}
APP_URL: {{ .Values.env.APP_URL | quote }}
{{- end }}
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
{{- if .Values.env.OIDC_CLIENT_ID }}
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISPLAY_NAME }}
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
{{- end }}
{{- if .Values.env.OIDC_ONLY }}
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_VALUE }}
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
{{- end }}
{{- if .Values.env.OIDC_SCOPE }}
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}
{{- if .Values.env.DEMO_MODE }}
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
{{- end }}
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
+30 -2
View File
@@ -11,6 +11,9 @@ spec:
app: {{ include "trek.name" . }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
app: {{ include "trek.name" . }}
spec:
@@ -20,21 +23,46 @@ spec:
- 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: JWT_SECRET
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
optional: true
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_EMAIL
optional: true
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_PASSWORD
optional: true
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
volumeMounts:
- name: data
mountPath: /app/data
+3
View File
@@ -10,6 +10,9 @@ metadata:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
+2
View File
@@ -1,3 +1,4 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -23,3 +24,4 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
{{- end }}
+29 -5
View File
@@ -1,4 +1,4 @@
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
apiVersion: v1
kind: Secret
metadata:
@@ -7,17 +7,41 @@ metadata:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
name: {{ $secretName }}
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- end }}
+65 -9
View File
@@ -15,21 +15,70 @@ service:
env:
NODE_ENV: production
PORT: 3000
# TZ: "UTC"
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
# COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
# TRUST_PROXY: "1"
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
# 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.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
# OIDC client ID.
# OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button.
# OIDC_ONLY: "false"
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
# OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
# Value of the OIDC claim that grants admin role.
# OIDC_SCOPE: "openid email profile groups"
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60.
# JWT secret configuration
# Secret environment variables stored in a Kubernetes Secret.
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
# rotatable via the admin panel) — it is not configured here.
secretEnv:
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
JWT_SECRET: ""
# 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: ""
# Initial admin account — only used on first boot when no users exist yet.
# If both values are non-empty the admin account is created with these credentials.
# If either is empty a random password is generated and printed to the server log.
ADMIN_EMAIL: ""
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
generateJwtSecret: false
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, use an existing Kubernetes secret for JWT_SECRET
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: JWT_SECRET
existingSecretKey: ENCRYPTION_KEY
persistence:
enabled: true
@@ -38,10 +87,17 @@ persistence:
uploads:
size: 1Gi
resources: {}
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: 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" />
<!-- 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>
<body>
<div id="root"></div>
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.7.0",
"version": "2.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.7.0",
"version": "2.8.3",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -2789,9 +2789,9 @@
}
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3693,9 +3693,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4679,9 +4679,9 @@
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5941,9 +5941,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -7181,9 +7181,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8705,9 +8705,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.7.1",
"version": "2.8.3",
"private": true,
"type": "module",
"scripts": {
+46 -7
View File
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
@@ -12,10 +11,12 @@ import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n'
import DemoBanner from './components/Layout/DemoBanner'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
interface ProtectedRouteProps {
children: ReactNode
@@ -23,8 +24,12 @@ interface 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 location = useLocation()
if (isLoading) {
return (
@@ -41,6 +46,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
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') {
return <Navigate to="/dashboard" replace />
}
@@ -63,17 +77,21 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
if (!location.pathname.startsWith('/shared/')) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
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')
@@ -99,13 +117,26 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
}
}, [isAuthenticated])
const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/')
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 applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
@@ -121,7 +152,7 @@ export default function App() {
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode])
}, [settings.dark_mode, isSharedPage])
return (
<TranslationProvider>
@@ -187,6 +218,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
+58
View File
@@ -0,0 +1,58 @@
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
}
}
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
const MAX_CONCURRENT = 6
let active = 0
const queue: Array<() => void> = []
function dequeue() {
while (active < MAX_CONCURRENT && queue.length > 0) {
active++
queue.shift()!()
}
}
export function clearImageQueue() {
queue.length = 0
}
export async function fetchImageAsBlob(url: string): Promise<string> {
if (!url) return ''
return new Promise<string>((resolve) => {
const run = async () => {
try {
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) { resolve(''); return }
const blob = await resp.blob()
resolve(URL.createObjectURL(blob))
} catch {
resolve('')
} finally {
active--
dequeue()
}
}
if (active < MAX_CONCURRENT) {
active++
run()
} else {
queue.push(run)
}
})
}
+48 -12
View File
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token and socket ID
// Request interceptor - add socket ID
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
@@ -28,12 +25,18 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
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)
}
)
@@ -44,7 +47,7 @@ export const authApi = {
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),
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),
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),
@@ -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),
deleteOwnAccount: () => apiClient.delete('/auth/me').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 = {
@@ -75,6 +83,7 @@ export const tripsApi = {
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
}
export const daysApi = {
@@ -95,6 +104,8 @@ export const placesApi = {
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 = {
@@ -151,7 +162,6 @@ export const adminApi = {
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),
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),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -170,6 +180,13 @@ export const adminApi = {
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),
sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
}
export const addonsApi = {
@@ -267,9 +284,8 @@ export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename: string): Promise<void> => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
@@ -302,6 +318,26 @@ 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 const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
+42 -12
View File
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
let currentToken: string | null = null
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
export function getSocketId(): string | null {
return mySocketId
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
function getWsUrl(token: string): string {
function getWsUrl(wsToken: string): string {
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 {
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (currentToken) {
connectInternal(currentToken, true)
if (shouldReconnect) {
connectInternal(true)
}
}, reconnectDelay)
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)) {
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.onopen = () => {
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
socket.onclose = () => {
socket = null
if (currentToken) {
if (shouldReconnect) {
scheduleReconnect()
}
}
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
}
}
export function connect(token: string): void {
currentToken = token
export function connect(): void {
shouldReconnect = true
reconnectDelay = 1000
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
connectInternal(token, false)
connectInternal(false)
}
export function disconnect(): void {
currentToken = null
shouldReconnect = false
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
+23 -8
View File
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
}
interface Addon {
@@ -32,6 +33,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
const [addons, setAddons] = useState([])
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))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
window.dispatchEvent(new Event('addons-changed'))
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
} catch (err: unknown) {
// Rollback
@@ -68,6 +70,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
if (loading) {
return (
@@ -144,6 +147,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
))}
</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>
@@ -188,11 +206,8 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)',
}}>
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
@@ -0,0 +1,120 @@
import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Key, Trash2, User, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface AdminMcpToken {
id: number
name: string
token_prefix: string
created_at: string
last_used_at: string | null
user_id: number
username: string
}
export default function AdminMcpTokensPanel() {
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const toast = useToast()
const { t, locale } = useTranslation()
useEffect(() => {
setIsLoading(true)
adminApi.mcpTokens()
.then(d => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setIsLoading(false))
}, [])
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id)
setTokens(prev => prev.filter(tk => tk.id !== id))
setDeleteConfirmId(null)
toast.success(t('admin.mcpTokens.deleteSuccess'))
} catch {
toast.error(t('admin.mcpTokens.deleteError'))
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</>
)}
</div>
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -15,7 +15,11 @@ interface AuditEntry {
ip: string | null
}
export default function AuditLogPanel(): React.ReactElement {
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)
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
const fmtTime = (iso: string) => {
try {
return new Date(iso).toLocaleString(locale, {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
} catch {
return iso
+4 -2
View File
@@ -324,9 +324,11 @@ export default function BackupPanel() {
</div>
<button
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>
</label>
@@ -0,0 +1,343 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
interface Trip {
id: number
title: string
}
interface AppUser {
id: number
username: string
email: string
}
export default function DevNotificationsPanel(): React.ReactElement {
const toast = useToast()
const user = useAuthStore(s => s.user)
const [sending, setSending] = useState<string | null>(null)
const [trips, setTrips] = useState<Trip[]>([])
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
useEffect(() => {
tripsApi.list().then(data => {
const list = (data.trips || data || []) as Trip[]
setTrips(list)
if (list.length > 0) setSelectedTripId(list[0].id)
}).catch(() => {})
adminApi.users().then(data => {
const list = (data.users || data || []) as AppUser[]
setUsers(list)
if (list.length > 0) setSelectedUserId(list[0].id)
}).catch(() => {})
}, [])
const send = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: any) {
toast.error(err.message || 'Failed')
} finally {
setSending(null)
}
}
const buttons = [
{
label: 'Simple → Me',
icon: Bell,
color: '#6366f1',
payload: {
type: 'simple',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.title',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.text',
text_params: {},
},
},
{
label: 'Boolean → Me',
icon: CheckCircle,
color: '#10b981',
payload: {
type: 'boolean',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.booleanTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.booleanText',
text_params: {},
positive_text_key: 'notifications.test.accept',
negative_text_key: 'notifications.test.decline',
positive_callback: { action: 'test_approve', payload: {} },
negative_callback: { action: 'test_deny', payload: {} },
},
},
{
label: 'Navigate → Me',
icon: Navigation,
color: '#f59e0b',
payload: {
type: 'navigate',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.navigateTitle',
title_params: {},
text_key: 'notifications.test.navigateText',
text_params: {},
navigate_text_key: 'notifications.test.goThere',
navigate_target: '/dashboard',
},
},
{
label: 'Simple → Admins',
icon: Zap,
color: '#ef4444',
payload: {
type: 'simple',
scope: 'admin',
target: 0,
title_key: 'notifications.test.adminTitle',
title_params: {},
text_key: 'notifications.test.adminText',
text_params: { actor: user?.username || 'Admin' },
},
},
]
return (
<div className="space-y-6">
<div className="flex items-center gap-2 mb-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Notification Testing
</span>
</div>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
</p>
{/* Quick-fire buttons */}
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{buttons.map(btn => {
const Icon = btn.icon
return (
<button
key={btn.label}
onClick={() => send(btn.label, btn.payload)}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${btn.color}20`, color: btn.color }}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
{btn.payload.type} · {btn.payload.scope}
</p>
</div>
{sending === btn.label && (
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
)}
</button>
)
})}
</div>
</div>
{/* Trip-scoped notifications */}
{trips.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => (
<option key={trip.id} value={trip.id}>{trip.title}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedTripId && send('Simple → Trip', {
type: 'simple',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
<Send className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
</div>
</button>
<button
onClick={() => selectedTripId && send('Navigate → Trip', {
type: 'navigate',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
navigate_text_key: 'notifications.test.goThere',
navigate_target: `/trips/${selectedTripId}`,
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
</div>
</button>
</div>
</div>
)}
{/* User-scoped notifications */}
{users.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'simple',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.title',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.text',
text_params: {},
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#06b6d420', color: '#06b6d4' }}>
<User className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'boolean',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.booleanTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.booleanText',
text_params: {},
positive_text_key: 'notifications.test.accept',
negative_text_key: 'notifications.test.decline',
positive_callback: { action: 'test_approve', payload: {} },
negative_callback: { action: 'test_deny', payload: {} },
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#10b98120', color: '#10b981' }}>
<CheckCircle className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'navigate',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.navigateTitle',
title_params: {},
text_key: 'notifications.test.navigateText',
text_params: {},
navigate_text_key: 'notifications.test.goThere',
navigate_target: '/dashboard',
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
</div>
</button>
</div>
</div>
)}
</div>
)
}
+25 -3
View File
@@ -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) => {
return text
return escapeHtml(text)
.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, '<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) {
@@ -115,7 +119,7 @@ export default function GitHubPanel() {
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
@@ -152,6 +156,24 @@ export default function GitHubPanel() {
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
@@ -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>
)
}
+145 -70
View File
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { BudgetItem, BudgetMember } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
@@ -59,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── 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 [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
@@ -86,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
: (value || '')
return (
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
</div>
)
@@ -99,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
// ── Add Item Row ─────────────────────────────────────────────────────────────
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
}
@@ -109,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const [expenseDate, setExpenseDate] = useState('')
const nameRef = useRef(null)
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
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(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -132,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
</td>
<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()}
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 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()}
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 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 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' }}>
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
@@ -227,9 +235,10 @@ interface BudgetMemberChipsProps {
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
readOnly?: boolean
}
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
@@ -271,17 +280,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
{members.map(m => (
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
paid={!!m.paid}
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
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>
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
)}
{showDropdown && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
@@ -412,12 +423,14 @@ interface BudgetPanelProps {
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
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 canEdit = can('budget_edit', trip)
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
@@ -470,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
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:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
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 td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
@@ -482,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</div>
<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>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
</div>
)
}
@@ -504,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
</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 style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -518,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
{editingCat?.name === cat ? (
{canEdit && editingCat?.name === cat ? (
<input
autoFocus
value={editingCat.value}
@@ -530,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
) : (
<>
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
<button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
{canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
)}
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
{canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
)}
</div>
</div>
@@ -552,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 60 }}>{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: 45 }}>{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: 80 }}>{t('budget.table.perDay')}</th>
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</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: 55 }}>{t('budget.table.days')}</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: 90 }}>{t('budget.table.perDay')}</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>
</tr>
</thead>
@@ -574,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<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 */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
@@ -584,12 +643,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</td>
<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 className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{hasMultipleMembers ? (
@@ -598,29 +658,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
tripMembers={tripMembers}
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 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 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 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' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
</button>
)}
</td>
</tr>
)
})}
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
</tbody>
</table>
</div>
@@ -629,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})}
</div>
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
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 }}>
<Plus size={16} />
</button>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
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 }}>
<Plus size={16} />
</button>
</div>
)}
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
@@ -666,7 +741,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
</div>
</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) })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</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 { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -353,6 +355,9 @@ interface CollabChatProps {
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const { t } = useTranslation()
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 [loading, setLoading] = useState(true)
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
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 => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
if (now - lastTap < 300) {
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
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',
...(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',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
>
<Reply size={11} />
</button>
{own && (
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
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>
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
/>
{/* Send */}
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
+61 -28
View File
@@ -5,6 +5,9 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -94,22 +97,33 @@ interface FilePreviewPortalProps {
}
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const [authUrl, setAuthUrl] = useState('')
const rawUrl = file?.url || ''
useEffect(() => {
if (!rawUrl) return
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
}, [rawUrl])
if (!file) return null
const url = file.url || `/uploads/${file.filename}`
const isImage = file.mime_type?.startsWith('image/')
const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = async () => {
const u = await getAuthUrl(rawUrl, 'download')
window.open(u, '_blank', 'noreferrer')
}
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
<img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
</div>
</div>
@@ -120,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
<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 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
</p>
</object>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
</div>
)}
</div>
@@ -142,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
)
}
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
}
const NOTE_COLORS = [
{ value: '#6366f1', label: 'Indigo' },
{ value: '#ef4444', label: 'Red' },
@@ -216,7 +238,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
interface NoteFormModalProps {
onClose: () => 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[]
categoryColors: Record<string, string>
getCategoryColor: (category: string) => string
@@ -226,6 +248,9 @@ interface 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 allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -298,6 +323,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
}}
onClick={e => e.stopPropagation()}
onPaste={e => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -450,11 +476,11 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
</div>
{/* File attachments */}
<div>
{canUploadFiles && <div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{existingAttachments.map(a => {
@@ -478,12 +504,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
</button>
</div>
))}
<label htmlFor="note-file-input"
<button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Plus size={11} /> {t('files.attach') || 'Add'}
</label>
</button>
</div>
</div>
</div>}
{/* Submit */}
<button
@@ -689,6 +715,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
interface NoteCardProps {
note: CollabNote
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
@@ -699,7 +726,7 @@ interface NoteCardProps {
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 author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -760,24 +787,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
<Maximize2 size={10} />
</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' }}
onMouseEnter={e => e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
</button>
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
</button>}
{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' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={10} />
</button>
<button onClick={handleDelete} title={t('collab.notes.delete')}
</button>}
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={10} />
</button>
</button>}
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
{/* Author avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}
@@ -838,7 +865,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return isImage ? (
<img key={a.id} src={a.url} alt={a.original_name}
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => onPreviewFile?.(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
@@ -879,6 +906,9 @@ interface CollabNotesProps {
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -964,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
}
// Reload note with attachments
const fresh = await collabApi.getNotes(tripId)
@@ -1124,17 +1154,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{t('collab.notes.title')}
</h3>
<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' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>
<button onClick={() => setShowNewModal(true)}
</button>}
{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' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>
</button>}
</div>
</div>
@@ -1252,6 +1282,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
@@ -1303,12 +1334,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
)}
</div>
<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 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -1327,6 +1358,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{showNewModal && (
<NoteFormModal
note={null}
tripId={tripId}
onClose={() => setShowNewModal(false)}
onSubmit={handleCreateNote}
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 { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
interface PollCardProps {
poll: Poll
currentUser: User
canEdit: boolean
onVote: (pollId: number, optionId: number) => Promise<void>
onClose: (pollId: number) => Promise<void>
onDelete: (pollId: number) => Promise<void>
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 isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
{canEdit && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!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 }}
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)'}>
<Lock size={12} />
<Trash2 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 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={12} />
</button>
</div>
</div>
)}
</div>
{/* Options */}
@@ -337,6 +342,9 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')}
</h3>
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
</button>
{canEdit && (
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
</button>
)}
</div>
{/* Content */}
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{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 && (
<>
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
</div>
)}
{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} />
))}
</>
)}
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
interface TripMember {
@@ -4,11 +4,17 @@ import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'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 }))
+59 -28
View File
@@ -1,11 +1,15 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
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) {
if (!mimeType) return false
@@ -41,6 +45,10 @@ interface ImageLightboxProps {
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
const [imgSrc, setImgSrc] = useState('')
useEffect(() => {
getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file.url])
return (
<div
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()}>
<img
src={file.url}
src={imgSrc}
alt={file.original_name}
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' }}>
<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 }}>
<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} />
</a>
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</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
interface SourceBadgeProps {
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 [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -247,6 +270,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handlePaste = useCallback((e) => {
if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
@@ -281,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
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 handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
@@ -311,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
@@ -326,7 +356,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
onClick={() => !isTrash && openFile(file)}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -334,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}}
>
{isImage(file.mime_type)
? <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 isPdf = file.mime_type === 'application/pdf'
@@ -355,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
)}
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<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' }}
>
{file.original_name}
@@ -386,14 +416,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{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)'}>
<RotateCcw size={14} />
</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' }}
</button>}
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</button>}
</>
) : (
<>
@@ -401,18 +431,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</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)'}>
<Pencil size={14} />
</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>}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => 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)'}>
<Trash2 size={14} />
</button>
</button>}
</>
)}
</div>
@@ -622,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<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 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<button
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
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' }}
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')}
</a>
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -637,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
</div>
<object
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
>
<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>
</object>
</div>
@@ -675,7 +706,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{showTrash ? (
/* Trash view */
<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 }}>
<button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
@@ -704,7 +735,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
) : (
<>
{/* Upload zone */}
<div
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
@@ -729,7 +760,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</p>
</>
)}
</div>
</div>}
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
const [open, setOpen] = useState(false)
useEffect(() => {
if (isAuthenticated) {
fetchUnreadCount()
}
}, [isAuthenticated])
const handleOpen = () => {
if (!open) {
fetchNotifications(true)
}
setOpen(v => !v)
}
const handleShowAll = () => {
setOpen(false)
navigate('/notifications')
}
const displayCount = unreadCount > 99 ? '99+' : unreadCount
return (
<div className="relative flex-shrink-0">
<button
onClick={handleOpen}
title={t('notifications.title')}
className="relative p-2 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{
background: '#ef4444',
fontSize: 9,
minWidth: 14,
height: 14,
padding: '0 3px',
lineHeight: 1,
}}
>
{displayCount}
</span>
)}
</button>
{open && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
style={{ background: '#6366f1', color: '#fff' }}>
{unreadCount}
</span>
)}
</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
)}
</div>
{/* Footer */}
<button
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
style={{
borderTop: '1px solid var(--border-secondary)',
color: '#6366f1',
background: 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{t('notifications.showAll')}
</button>
</div>
</>,
document.body
)}
</div>
)
}
+23 -18
View File
@@ -3,10 +3,11 @@ import ReactDOM from 'react-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
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 type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
@@ -28,29 +29,21 @@ interface Addon {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const loadAddons = () => {
if (user) {
addonsApi.enabled().then(data => {
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
}).catch(() => {})
}
}
useEffect(loadAddons, [user, location.pathname])
// Listen for addon changes from AddonManager
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
useEffect(() => {
const handler = () => loadAddons()
window.addEventListener('addons-changed', handler)
return () => window.removeEventListener('addons-changed', handler)
}, [user])
if (user) loadAddons()
}, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
@@ -171,6 +164,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* Notification bell */}
{user && <InAppNotificationBell />}
{/* User menu */}
{user && (
<div className="relative">
@@ -236,9 +232,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
{appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
title="Discord">
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
</div>
</div>
)}
+168 -114
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
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'
// 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;')
}
const iconCache = new Map<string, L.DivIcon>()
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 borderColor = isSelected ? '#111827' : 'white'
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 2px 8px rgba(0,0,0,0.22)'
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 = ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>`
}
if (place.image_url) {
return L.divIcon({
// Base64 data URL thumbnails — no external image fetch during zoom
// 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: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:visible;background:${bgColor};
cursor:pointer;flex-shrink:0;position:relative;
width:${size}px;height:${size}px;
cursor:pointer;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
">
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, imgIcon)
return imgIcon
}
return L.divIcon({
const fallbackIcon = L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
background:${bgColor};
display:flex;align-items:center;justify-content:center;
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}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, fallbackIcon)
return fallbackIcon
}
interface SelectionControllerProps {
@@ -166,6 +187,16 @@ interface MapClickHandlerProps {
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) {
const map = useMap()
useEffect(() => {
@@ -237,8 +268,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
}
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
const mapPhotoInFlight = new Set()
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
@@ -330,7 +360,7 @@ function LocationTracker() {
)
}
export function MapView({
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
route = null,
@@ -358,54 +388,110 @@ export function MapView({
const right = rightWidth + 40
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
// Fetch photos for places with concurrency limit to avoid blocking map rendering
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => {
const queue = places.filter(place => {
if (place.image_url) return false
if (!places || places.length === 0) 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}`
if (!cacheKey) return false
if (mapPhotoCache.has(cacheKey)) {
const cached = mapPhotoCache.get(cacheKey)
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
return false
if (!cacheKey) continue
const cached = getCached(cacheKey)
if (cached?.thumbDataUrl) {
setThumb(cacheKey, cached.thumbDataUrl)
continue
}
if (mapPhotoInFlight.has(cacheKey)) return false
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) return false
return true
})
let active = 0
const MAX_CONCURRENT = 3
let idx = 0
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
const fetchNext = () => {
while (active < MAX_CONCURRENT && idx < queue.length) {
const place = queue[idx++]
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
const photoId = place.google_place_id || place.osm_id
mapPhotoInFlight.add(cacheKey)
active++
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(cacheKey, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
} else {
mapPhotoCache.set(cacheKey, null)
}
})
.catch(() => { mapPhotoCache.set(cacheKey, null) })
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
}
}
fetchNext()
}, [places])
return () => cleanups.forEach(fn => fn())
}, [placeIds])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}, [])
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
permanent={isTouchDevice && isSelected}
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
return (
<MapContainer
id="trek-map"
center={center}
zoom={zoom}
zoomControl={false}
@@ -416,6 +502,10 @@ export function MapView({
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
<MapController center={center} zoom={zoom} />
@@ -427,71 +517,17 @@ export function MapView({
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
singleMarkerMode
iconCreateFunction={(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),
})
}}
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{places.map((place) => {
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>
)
})}
{markers}
</MarkerClusterGroup>
{route && route.length > 1 && (
@@ -508,6 +544,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>
)
}
})
+1 -1
View File
@@ -13,7 +13,7 @@ export async function calculateRoute(
}
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 })
if (!response.ok) {
+198 -25
View File
@@ -1,8 +1,23 @@
import { useState, useEffect, useCallback } from 'react'
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react'
import apiClient from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
const [src, setSrc] = useState('')
useEffect(() => {
let revoke = ''
fetchImageAsBlob(baseUrl).then(blobUrl => {
revoke = blobUrl
setSrc(blobUrl)
})
return () => { if (revoke) URL.revokeObjectURL(revoke) }
}, [baseUrl])
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
}
// ── Types ───────────────────────────────────────────────────────────────────
@@ -31,6 +46,7 @@ interface MemoriesPanelProps {
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
@@ -52,11 +68,65 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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([]); toast.error(t('memories.error.loadAlbums')) }
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 { toast.error(t('memories.error.linkAlbum')) }
}
const unlinkAlbum = async (linkId: number) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
loadAlbumLinks()
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
const syncAlbum = async (linkId: number) => {
setSyncing(linkId)
try {
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.syncAlbum')) }
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 ──────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setConnected(false)
}
await loadPhotos()
await loadAlbumLinks()
setLoading(false)
}
@@ -114,6 +185,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setPickerPhotos(res.data.assets || [])
} catch {
setPickerPhotos([])
toast.error(t('memories.error.loadPhotos'))
} finally {
setPickerLoading(false)
}
@@ -141,8 +213,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
shared: true,
})
setShowPicker(false)
clearImageQueue()
loadInitial()
} catch {}
} catch { toast.error(t('memories.error.addPhotos')) }
}
// ── Remove photo ──────────────────────────────────────────────────────────
@@ -151,7 +224,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
} catch {}
} catch { toast.error(t('memories.error.removePhoto')) }
}
// ── Toggle sharing ────────────────────────────────────────────────────────
@@ -162,18 +235,13 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setTripPhotos(prev => prev.map(p =>
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch {}
} catch { toast.error(t('memories.error.toggleSharing')) }
}
// ── Helpers ───────────────────────────────────────────────────────────────
const token = useAuthStore(s => s.token)
const thumbnailUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
const originalUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
const 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)
@@ -224,6 +292,72 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── 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))
@@ -237,7 +371,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{t('memories.selectPhotos')}
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowPicker(false)}
<button onClick={() => { clearImageQueue(); 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>
@@ -328,7 +462,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
@@ -404,16 +538,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</p>
</div>
{connected && (
<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 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 */}
@@ -470,12 +640,15 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).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))
}}>
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
{/* Other user's avatar */}
@@ -577,12 +750,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{/* Lightbox */}
{lightboxId && lightboxUserId && (
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); 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) }}
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); 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',
@@ -592,7 +765,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</button>
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
<img
src={originalUrl(lightboxId, lightboxUserId)}
src={lightboxOriginalSrc}
alt=""
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
/>
@@ -0,0 +1,195 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore'
function relativeTime(dateStr: string, locale: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
interface NotificationItemProps {
notification: InAppNotification
onClose?: () => void
}
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const [responding, setResponding] = useState(false)
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
const handleNavigate = async () => {
if (!notification.is_read) await markRead(notification.id)
if (notification.navigate_target) {
navigate(notification.navigate_target)
onClose?.()
}
}
const handleRespond = async (response: 'positive' | 'negative') => {
if (responding || notification.response !== null) return
setResponding(true)
await respondToBoolean(notification.id, response)
setResponding(false)
}
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
return (
<div
className="relative px-4 py-3 transition-colors"
style={{
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
borderBottom: '1px solid var(--border-secondary)',
}}
>
{/* Unread dot */}
{!notification.is_read && (
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full" style={{ background: '#6366f1' }} />
)}
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
>
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />
}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
{relativeTime(notification.created_at, locale)}
</span>
{!notification.is_read && (
<button
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<button
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'positive'
? '#6366f1'
: notification.response === 'negative'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'positive'
? '#fff'
: notification.response === 'negative'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'negative' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<Check className="w-3 h-3" />
{t(notification.positive_text_key)}
</button>
<button
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'negative'
? '#ef4444'
: notification.response === 'positive'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'negative'
? '#fff'
: notification.response === 'positive'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'positive' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<X className="w-3 h-3" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
>
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)}
</button>
)}
</div>
</div>
</div>
)
}
+4 -4
View File
@@ -61,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
const f = new Date(dd[0].date + 'T00:00:00Z')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
}
function dayCost(assignments, dayId, locale) {
@@ -1,5 +1,6 @@
import { useState, useMemo, useRef, useEffect } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi, adminApi } from '../../api/client'
@@ -77,9 +78,10 @@ interface ArtikelZeileProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
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 [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
@@ -130,7 +132,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{editing ? (
{editing && canEdit ? (
<input
type="text" value={editName} autoFocus
onChange={e => setEditName(e.target.value)}
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
/>
) : (
<span
onClick={() => !item.checked && setEditing(true)}
onClick={() => canEdit && !item.checked && setEditing(true)}
style={{
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)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -159,7 +161,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<input
type="text" inputMode="numeric"
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onChange={async e => {
if (!canEdit) return
const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
@@ -171,9 +175,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
</div>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowBagPicker(p => !p)}
onClick={() => canEdit && setShowBagPicker(p => !p)}
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)',
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}}
@@ -247,6 +251,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
</div>
)}
{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 style={{ position: 'relative' }}>
<button
@@ -287,6 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<Trash2 size={13} />
</button>
</div>
)}
</div>
)
}
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
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 [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -380,7 +387,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName ? (
{editingName && canEdit ? (
<input
autoFocus value={editKatName}
onChange={e => setEditKatName(e.target.value)}
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
{assignees.map(a => (
<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"
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%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
@@ -422,6 +429,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</div>
</div>
))}
{canEdit && (
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
style={{
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</div>
)}
</div>
)}
</div>
<span style={{
@@ -497,11 +506,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{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 }}
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={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
{canEdit && <>
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
)}
</div>
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{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 */}
{showAddItem ? (
{canEdit && (showAddItem ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
<input
ref={addItemRef}
@@ -548,7 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')}
</button>
)}
))}
</div>
)}
</div>
@@ -589,6 +600,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t } = useTranslation()
@@ -814,7 +828,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{abgehakt > 0 && (
{canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
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',
@@ -823,6 +837,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{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',
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
{availableTemplates.length > 0 && (
)}
{canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -899,7 +915,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
)}
{addingCategory ? (
{canEdit && (addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<input
autoFocus
@@ -924,7 +940,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
</button>
)}
))}
</div>
{/* ── Filter-Tabs ── */}
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
/>
))}
</div>
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
{canEdit && (
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<X size={11} />
</button>
)}
</div>
<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' }} />
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
{/* Add bag */}
{showAddBag ? (
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1054,16 +1073,16 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
)}
))}
</div>
)}
</div>
{/* ── Bag Modal (mobile + click) ── */}
{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)}>
<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()}>
<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>
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
{canEdit && (
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<Trash2 size={13} />
</button>
)}
</div>
<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' }} />
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
{/* Add bag */}
{showAddBag ? (
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1142,7 +1163,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')}
</button>
)}
))}
</div>
</div>
)}
@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -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_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 { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
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 is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
@@ -111,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setAccommodation(data.accommodation)
setAccommodations(prev => [...prev, data.accommodation])
const newAcc = 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)
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
@@ -132,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!accommodation) return
try {
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)
onAccommodationChange?.()
} catch {}
@@ -140,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long' }
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
) : null
const placesWithCoords = places.filter(p => p.lat && p.lng)
@@ -328,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</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 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
</button>}
{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)' }} />
</button>
</button>}
</div>
{/* Details grid */}
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
@@ -385,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
})}
{/* Add another hotel */}
<button onClick={() => setShowHotelPicker(true)} style={{
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
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,
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={10} /> {t('day.addAccommodation')}
</button>
</button>}
</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,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={12} /> {t('day.addAccommodation')}
</button>
</button> : null
)}
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
@@ -431,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>
@@ -443,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>
@@ -549,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
// Reload
accommodationsApi.list(tripId).then(d => {
setAccommodations(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))
const all = d.accommodations || []
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)
})
onAccommodationChange?.()
+243 -120
View File
@@ -2,9 +2,9 @@
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -12,10 +12,13 @@ import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
@@ -76,9 +79,14 @@ interface DayPlanSidebarProps {
reservations?: Reservation[]
onAddReservation: () => void
onNavigateToFiles?: () => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
}
export default function DayPlanSidebar({
const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId,
@@ -88,12 +96,19 @@ export default function DayPlanSidebar({
reservations = [],
onAddReservation,
onNavigateToFiles,
onExpandedDaysChange,
pushUndo,
canUndo = false,
lastActionLabel = null,
onUndo,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
@@ -104,6 +119,7 @@ export default function DayPlanSidebar({
} catch {}
return new Set(days.map(d => d.id))
})
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [isCalculating, setIsCalculating] = useState(false)
@@ -111,6 +127,9 @@ export default function DayPlanSidebar({
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
@@ -323,6 +342,16 @@ export default function DayPlanSidebar({
return result.sort((a, b) => a.sortKey - b.sortKey)
}
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
// eslint-disable-next-line react-hooks/exhaustive-deps
const mergedItemsMap = useMemo(() => {
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
return map
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
_openAddNote(dayId, getMergedItems, (id) => {
@@ -377,6 +406,9 @@ export default function DayPlanSidebar({
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Capture previous place order for undo
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
// Places get sequential integer positions (0, 1, 2, ...)
// Non-place items between place N-1 and place N get fractional positions
const assignmentIds: number[] = []
@@ -410,7 +442,7 @@ export default function DayPlanSidebar({
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteUpdates) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
if (transportUpdates.length) {
for (const tu of transportUpdates) {
@@ -419,6 +451,13 @@ export default function DayPlanSidebar({
}
await reservationsApi.updatePositions(tripId, transportUpdates)
}
if (prevAssignmentIds.length) {
const capturedDayId = dayId
const capturedPrevIds = prevAssignmentIds
pushUndo?.(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
@@ -503,7 +542,7 @@ export default function DayPlanSidebar({
currentAssignments[key] = currentAssignments[key].map(a =>
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
)
tripStore.setAssignments(currentAssignments)
tripActions.setAssignments(currentAssignments)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
@@ -581,12 +620,14 @@ export default function DayPlanSidebar({
}
const toggleLock = (assignmentId) => {
const prevLocked = new Set(lockedIds)
setLockedIds(prev => {
const next = new Set(prev)
if (next.has(assignmentId)) next.delete(assignmentId)
else next.add(assignmentId)
return next
})
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
}
const handleOptimize = async () => {
@@ -594,6 +635,8 @@ export default function DayPlanSidebar({
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const prevIds = da.map(a => a.id)
// Separate locked (stay at their index) and unlocked assignments
const locked = new Map() // index -> assignment
const unlocked = []
@@ -620,6 +663,10 @@ export default function DayPlanSidebar({
await onReorder(selectedDayId, result.map(a => a.id))
toast.success(t('dayplan.toast.routeOptimized'))
const capturedDayId = selectedDayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
})
}
const handleGoogleMaps = () => {
@@ -632,14 +679,24 @@ export default function DayPlanSidebar({
const handleDropOnDay = (e, dayId) => {
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
const capturedFromDayId = fromDayId
const capturedOrderIndex = srcAssignment?.order_index ?? 0
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
.then(() => {
pushUndo?.(t('undo.moveDay'), async () => {
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
})
})
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
setDraggingId(null)
setDropTargetKey(null)
@@ -668,10 +725,10 @@ export default function DayPlanSidebar({
setDraggingId(null)
}
const totalCost = days.reduce((s, d) => {
const totalCost = useMemo(() => days.reduce((s, d) => {
const da = assignments[String(d.id)] || []
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
}, 0)
}, 0), [days, assignments])
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
@@ -686,62 +743,124 @@ export default function DayPlanSidebar({
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' ')}
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
title={t('dayplan.pdfTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('ICS export failed') }
}}
title={t('dayplan.icsTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
ICS
</button>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
onMouseEnter={() => setPdfHover(true)}
onMouseLeave={() => setPdfHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
{pdfHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
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)',
}}>
{t('dayplan.pdfTooltip')}
</div>
)}
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
credentials: 'include',
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('ICS export failed') }
}}
onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
ICS
</button>
{icsHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
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)',
}}>
{t('dayplan.icsTooltip')}
</div>
)}
</div>
{onUndo && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={onUndo}
disabled={!canUndo}
onMouseEnter={() => setUndoHover(true)}
onMouseLeave={() => setUndoHover(false)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: canUndo ? 'var(--text-primary)' : 'var(--border-primary)',
cursor: canUndo ? 'pointer' : 'default', fontFamily: 'inherit',
transition: 'color 0.15s, border-color 0.15s',
}}
>
<Undo2 size={14} strokeWidth={2} />
</button>
{undoHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
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)',
}}>
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
</div>
)}
</div>
)}
</div>
</div>
@@ -755,7 +874,7 @@ export default function DayPlanSidebar({
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
const isDragTarget = dragOverDayId === day.id
const merged = getMergedItems(day.id)
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
@@ -777,6 +896,7 @@ export default function DayPlanSidebar({
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0,
touchAction: 'manipulation',
}}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
@@ -810,15 +930,15 @@ export default function DayPlanSidebar({
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
<button
{canEditDays && <button
onClick={e => startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>}
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last
@@ -862,20 +982,20 @@ export default function DayPlanSidebar({
</div>
</div>
<button
{canEditDays && <button
onClick={e => openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
<FileText size={13} strokeWidth={2} />
</button>
<FileText size={16} strokeWidth={2} />
</button>}
<button
onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
>
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
</button>
</div>
@@ -886,6 +1006,7 @@ export default function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
e.stopPropagation()
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Drop on transport card (detected via dropTargetRef for sync accuracy)
if (dropTargetRef.current?.startsWith('transport-')) {
@@ -894,11 +1015,11 @@ export default function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
}
@@ -912,11 +1033,11 @@ export default function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null; return
}
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -992,8 +1113,9 @@ export default function DayPlanSidebar({
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
draggable={canEditDays}
onDragStart={e => {
if (!canEditDays) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1010,7 +1132,7 @@ export default function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -1018,7 +1140,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
@@ -1027,12 +1149,12 @@ export default function DayPlanSidebar({
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.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 },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
@@ -1050,9 +1172,9 @@ export default function DayPlanSidebar({
opacity: isDraggingThis ? 0.4 : 1,
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
</div>}
<div
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
@@ -1103,10 +1225,8 @@ export default function DayPlanSidebar({
)}
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{(() => {
@@ -1155,14 +1275,14 @@ export default function DayPlanSidebar({
</div>
)}
</div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
</div>}
</div>
</React.Fragment>
)
@@ -1199,11 +1319,11 @@ export default function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
}
@@ -1261,8 +1381,8 @@ export default function DayPlanSidebar({
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -1272,7 +1392,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -1280,17 +1400,17 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
]) : undefined}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
@@ -1304,9 +1424,9 @@ export default function DayPlanSidebar({
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
@@ -1315,17 +1435,17 @@ export default function DayPlanSidebar({
{note.text}
</span>
{note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
)}
</div>
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>
</div>}
</div>
</React.Fragment>
)
@@ -1345,11 +1465,11 @@ export default function DayPlanSidebar({
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -1406,7 +1526,7 @@ export default function DayPlanSidebar({
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
<div key={dayId} style={{
position: 'fixed', inset: 0, zIndex: 1000,
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
@@ -1423,8 +1543,8 @@ export default function DayPlanSidebar({
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
@@ -1434,8 +1554,9 @@ export default function DayPlanSidebar({
value={ui.text}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
placeholder={t('dayplan.noteTitle') + ' *'}
required
style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<textarea
value={ui.time}
@@ -1446,10 +1567,10 @@ export default function DayPlanSidebar({
placeholder={t('dayplan.noteSubtitle')}
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
/>
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} style={{ fontSize: 12, background: !ui.text?.trim() ? 'var(--border-primary)' : 'var(--accent)', color: !ui.text?.trim() ? 'var(--text-faint)' : 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
@@ -1550,7 +1671,7 @@ export default function DayPlanSidebar({
{res.reservation_time?.includes('T')
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
: res.reservation_time
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: ''
}
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
@@ -1594,13 +1715,13 @@ export default function DayPlanSidebar({
{res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
</div>
)}
{/* Dateien */}
{(() => {
const resFiles = (tripStore.files || []).filter(f =>
const resFiles = (useTripStore.getState().files || []).filter(f =>
!f.deleted_at && (
f.reservation_id === res.id ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
@@ -1661,4 +1782,6 @@ export default function DayPlanSidebar({
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
})
export default DayPlanSidebar
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
useEffect(() => {
if (place) {
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
// Paste support for files/images
const handlePaste = (e) => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
</div>
{/* File Attachments */}
{true && (
{canUploadFiles && (
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
+104 -11
View File
@@ -1,5 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } 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 React, { useState, useEffect, useRef, useCallback, useMemo } from '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 { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
@@ -116,7 +119,7 @@ interface PlaceInspectorProps {
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<void>
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
@@ -339,10 +342,8 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
{place.description || place.notes || googleDetails?.summary}
</p>
<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' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
</div>
)}
@@ -372,7 +373,7 @@ export default function PlaceInspector({
{res.reservation_time && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{res.reservation_time?.includes('T') && (
@@ -391,7 +392,7 @@ export default function PlaceInspector({
</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 || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -461,6 +462,98 @@ export default function PlaceInspector({
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &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 */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
@@ -489,11 +582,11 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{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" />}
<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>}
</a>
</button>
))}
</div>
)}
+206 -59
View File
@@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
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'
interface PlacesSidebarProps {
@@ -28,17 +29,21 @@ interface PlacesSidebarProps {
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
export default function PlacesSidebar({
const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
const tripStore = useTripStore()
const trip = useTripStore((s) => s.trip)
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -46,12 +51,51 @@ export default function PlacesSidebar({
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
await tripStore.loadTrip(tripId)
await loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
} 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('')
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGoogleList'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.googleListError'))
} finally {
setGoogleListLoading(false)
}
}
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
@@ -67,11 +111,12 @@ export default function PlacesSidebar({
}
const [dayPickerPlace, setDayPickerPlace] = useState(null)
const [catDropOpen, setCatDropOpen] = useState(false)
const [mobileShowDays, setMobileShowDays] = useState(false)
// 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))
)
), [assignments])
const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
@@ -79,7 +124,7 @@ export default function PlacesSidebar({
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
}), [places, filter, categoryFilters, search, plannedIds.size])
}), [places, filter, categoryFilters, search, plannedIds])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -88,7 +133,7 @@ export default function PlacesSidebar({
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<button
{canEditPlaces && <button
onClick={onAddPlace}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
@@ -98,20 +143,36 @@ export default function PlacesSidebar({
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
</button>}
{canEditPlaces && <>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
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>
<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 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
@@ -223,9 +284,9 @@ export default function PlacesSidebar({
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</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')}
</button>
</button>}
</div>
) : (
filtered.map(place => {
@@ -245,19 +306,19 @@ export default function PlacesSidebar({
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (isMobile && days?.length > 0) {
if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}}
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) },
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') },
{ 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={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -312,49 +373,133 @@ export default function PlacesSidebar({
)}
</div>
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
{dayPickerPlace && ReactDOM.createPortal(
<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' }}
>
<div
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={{ 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 style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
{days.map((day, i) => {
return (
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
{/* View details */}
<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
key={day.id}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
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'}
onClick={() => setMobileShowDays(v => !v)}
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)' }}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
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>}
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</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:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</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>,
@@ -363,4 +508,6 @@ export default function PlacesSidebar({
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
})
export default PlacesSidebar
@@ -59,7 +59,7 @@ interface ReservationModalProps {
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
}
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
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')}
</button>
</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' }}>
@@ -572,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -56,9 +57,10 @@ interface ReservationCardProps {
files?: TripFile[]
onNavigateToFiles: () => void
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 toast = useToast()
const { t, locale } = useTranslation()
@@ -82,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
}
const fmtDate = (str) => {
const d = new Date(str)
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const dateOnly = str.includes('T') ? str.split('T')[0] : str
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const fmtTime = (str) => {
const d = new Date(str)
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* 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={{ 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' }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</button>
{canEdit ? (
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
{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)' }} />
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
<span style={{ flex: 1 }} />
<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 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
<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 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={11} />
</button>
{canEdit && (
<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 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
)}
{canEdit && (
<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 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={11} />
</button>
)}
</div>
{/* Details */}
@@ -131,7 +143,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ 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 }}>
{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>
)}
@@ -330,6 +342,9 @@ interface ReservationsPanelProps {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
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 assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
@@ -348,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
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>
{canEdit && (
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
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>
)}
</div>
{/* Content */}
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
<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>
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
<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>
</Section>
)}
+90 -17
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from 'react'
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 CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -23,13 +24,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const toast = useToast()
const { t } = useTranslation()
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({
title: '',
description: '',
start_date: '',
end_date: '',
reminder_days: 0 as number,
})
const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
@@ -41,25 +49,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
useEffect(() => {
if (trip) {
const rd = trip.reminder_days ?? 3
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
reminder_days: rd,
})
setCustomReminder(![0, 1, 3, 9].includes(rd))
setCoverPreview(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
setCustomReminder(false)
setCoverPreview(null)
}
setPendingCoverFile(null)
setSelectedMembers([])
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) {
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
}
}, [trip, isOpen])
useEffect(() => {
if (!trip && isOpen) {
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
}
}, [tripRemindersEnabled])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -74,6 +97,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
reminder_days: formData.reminder_days,
})
// Add selected members for newly created trips
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
const handlePaste = (e) => {
if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -172,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (!prev.end_date || prev.end_date < value) {
next.end_date = value
} else if (prev.start_date) {
const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00')
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
const duration = Math.round((oldEnd - oldStart) / 86400000)
const newEnd = new Date(value + 'T00:00:00')
const newEnd = new Date(value + 'T00:00:00Z')
newEnd.setDate(newEnd.getDate() + duration)
next.end_date = newEnd.toISOString().split('T')[0]
}
@@ -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>
)}
{/* Cover image — available for both create and edit */}
<div>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && <div>
<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} />
{coverPreview ? (
@@ -240,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</div>
</div>}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
</label>
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<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)}
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
@@ -272,6 +297,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</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 */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
@@ -312,11 +390,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</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>
</Modal>
)
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
)
}
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const [shareToken, setShareToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const toast = useToast()
const { user } = useAuthStore()
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(() => {
if (isOpen && tripId) {
@@ -247,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
{/* Left column: Members */}
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
{/* Add member dropdown */}
<div>
{canManageMembers && <div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
{t('members.inviteUser')}
</label>
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
</button>
</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>
)}
</div>
</div>}
{/* Members list */}
<div>
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{allMembers.map(member => {
const isSelf = member.id === user?.id
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
{/* Right column: Share Link */}
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} />
</div>
</div>}
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
@@ -81,6 +81,7 @@ export default function VacayMonthCard({
return (
<div
key={di}
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
className="relative flex items-center justify-center cursor-pointer transition-colors"
style={{
height: 28,
+8 -8
View File
@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
}
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00')
return weekendDays.includes(d.getDay())
const d = new Date(dateStr + 'T00:00:00Z')
return weekendDays.includes(d.getUTCDay())
}
export function getWeekday(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
}
export function getWeekdayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
}
export function daysInMonth(year: number, month: number): number {
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
}
export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
}
export { BUNDESLAENDER }
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
onChange: (value: string) => void
placeholder?: string
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 [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
const parsed = value ? new Date(value + 'T00:00:00Z') : null
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
}, [open])
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
}, [open])
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
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 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', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
const selectDay = (day: number) => {
const y = String(viewYear)
@@ -55,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
setOpen(false)
}
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
const today = new Date()
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
@@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
) : (
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
border: borderless ? 'none' : '1px solid var(--border-primary)',
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
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>
</button>
)}
@@ -19,6 +19,7 @@ interface CustomSelectProps {
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
disabled?: boolean
}
export default function CustomSelect({
@@ -29,6 +30,7 @@ export default function CustomSelect({
searchable = false,
style = {},
size = 'md',
disabled = false,
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -83,17 +85,19 @@ export default function CustomSelect({
{/* Trigger */}
<button
type="button"
onClick={() => { setOpen(o => !o); setSearch('') }}
disabled={disabled}
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
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,
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)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
if (is12h) return // let handleBlur parse 12h formats
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
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 = () => {
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)) {
const [hh, mm] = clean.split(':')
const h = Math.min(23, Math.max(0, parseInt(hh)))
+37 -42
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import type { Place } from '../../types'
interface Category {
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
category?: Category | null
}
const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
// Event-based notification instead of polling intervals
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
function notifyListeners(key: string, url: string | null) {
const listeners = photoListeners.get(key)
if (listeners) {
listeners.forEach(fn => fn(url))
photoListeners.delete(key)
}
}
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
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(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
const cached = getCached(cacheKey)
if (cached) {
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
if (!cached.thumbDataUrl && cached.photoUrl) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
return
}
if (photoInFlight.has(cacheKey)) {
// Subscribe to notification instead of polling
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
photoListeners.get(cacheKey)!.add(handler)
return () => { photoListeners.get(cacheKey)?.delete(handler) }
if (isLoading(cacheKey)) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {
photoCache.set(cacheKey, null)
notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
)
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
@@ -81,11 +76,11 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
if (photoSrc) {
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
@@ -94,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
}
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
import { addListener, removeListener } from '../api/websocket'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
export function useInAppNotificationListener(): void {
const handleNew = useInAppNotificationStore(s => s.handleNewNotification)
const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification)
useEffect(() => {
const listener = (event: Record<string, unknown>) => {
if (event.type === 'notification:new') {
handleNew(event.notification as any)
} else if (event.type === 'notification:updated') {
handleUpdated(event.notification as any)
}
}
addListener(listener)
return () => removeListener(listener)
}, [handleNew, handleUpdated])
}
+29
View File
@@ -0,0 +1,29 @@
import { useRef, useReducer } from 'react'
export interface UndoEntry {
label: string
undo: () => Promise<void> | void
}
export function usePlannerHistory(maxEntries = 30) {
const historyRef = useRef<UndoEntry[]>([])
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries)
forceUpdate()
}
const undo = async () => {
if (historyRef.current.length === 0) return
const [first, ...rest] = historyRef.current
historyRef.current = rest
forceUpdate()
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
}
const canUndo = historyRef.current.length > 0
const lastActionLabel = historyRef.current[0]?.label ?? null
return { pushUndo, undo, canUndo, lastActionLabel }
}
+9 -3
View File
@@ -15,11 +15,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
const tripStoreRef = useRef(tripStore)
tripStoreRef.current = tripStore
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const currentAssignments = tripStoreRef.current.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)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
@@ -33,12 +37,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (err instanceof Error && err.name !== 'AbortError') 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(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, tripStore.assignments])
}, [selectedDayId, selectedDayAssignments])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+5 -3
View File
@@ -12,6 +12,7 @@ import nl from './translations/nl'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
@@ -24,14 +25,15 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
{ value: 'cs', label: 'Česky' },
{ value: 'pl', label: 'Polski' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' },
] as const
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', 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 translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs, pl }
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', pl: 'pl-PL' }
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -40,7 +42,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+216 -12
View File
@@ -6,6 +6,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'Upravit',
'common.add': 'Přidat',
'common.loading': 'Načítání...',
'common.import': 'Importovat',
'common.error': 'Chyba',
'common.back': 'Zpět',
'common.all': 'Vše',
@@ -25,6 +26,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail',
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
'common.saved': 'Uloženo',
'trips.reminder': 'Připomínka',
'trips.reminderNone': 'Žádná',
'trips.reminderDay': 'den',
'trips.reminderDays': 'dní',
'trips.reminderCustom': 'Vlastní',
'trips.reminderDaysBefore': 'dní před odjezdem',
'trips.reminderDisabledHint': 'Připomínky výletů jsou zakázány. Povolte je v Správa > Nastavení > Oznámení.',
'common.update': 'Aktualizovat',
'common.change': 'Změnit',
'common.uploading': 'Nahrávání…',
@@ -72,7 +81,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Sdílí {name}',
'dashboard.days': 'Dní',
'dashboard.places': 'Míst',
'dashboard.members': 'Cestovní parťáci',
'dashboard.archive': 'Archivovat',
'dashboard.copyTrip': 'Kopírovat',
'dashboard.copySuffix': 'kopie',
'dashboard.restore': 'Obnovit',
'dashboard.archived': 'Archivováno',
'dashboard.status.ongoing': 'Probíhající',
@@ -91,7 +103,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu',
'dashboard.toast.restored': 'Cesta byla obnovena',
'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu',
'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.',
'dashboard.toast.copied': 'Cesta byla zkopírována!',
'dashboard.toast.copyError': 'Nepodařilo se zkopírovat cestu',
'dashboard.confirm.delete': 'Smazat cestu „{title}”? Všechna místa a plány budou trvale smazány.',
'dashboard.editTrip': 'Upravit cestu',
'dashboard.createTrip': 'Vytvořit novou cestu',
'dashboard.tripTitle': 'Název',
@@ -150,9 +164,38 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
'settings.notifyWebhook': 'Webhook oznámení',
'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.',
'settings.notificationsActive': 'Aktivní kanál',
'settings.notificationsManagedByAdmin': 'Události oznámení jsou konfigurovány administrátorem.',
'settings.on': 'Zapnuto',
'settings.off': 'Vypnuto',
'settings.mcp.title': 'Konfigurace MCP',
'settings.mcp.endpoint': 'MCP endpoint',
'settings.mcp.clientConfig': 'Konfigurace klienta',
'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
'settings.mcp.copy': 'Kopírovat',
'settings.mcp.copied': 'Zkopírováno!',
'settings.mcp.apiTokens': 'API tokeny',
'settings.mcp.createToken': 'Vytvořit nový token',
'settings.mcp.noTokens': 'Zatím žádné tokeny. Vytvořte jeden pro připojení MCP klientů.',
'settings.mcp.tokenCreatedAt': 'Vytvořen',
'settings.mcp.tokenUsedAt': 'Použit',
'settings.mcp.deleteTokenTitle': 'Smazat token',
'settings.mcp.deleteTokenMessage': 'Tento token přestane okamžitě fungovat. Všichni MCP klienti, kteří ho používají, ztratí přístup.',
'settings.mcp.modal.createTitle': 'Vytvořit API token',
'settings.mcp.modal.tokenName': 'Název tokenu',
'settings.mcp.modal.tokenNamePlaceholder': 'např. Claude Desktop, Pracovní notebook',
'settings.mcp.modal.creating': 'Vytváření…',
'settings.mcp.modal.create': 'Vytvořit token',
'settings.mcp.modal.createdTitle': 'Token vytvořen',
'settings.mcp.modal.createdWarning': 'Tento token bude zobrazen pouze jednou. Zkopírujte a uložte ho nyní — nelze ho obnovit.',
'settings.mcp.modal.done': 'Hotovo',
'settings.mcp.toast.created': 'Token vytvořen',
'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
'settings.mcp.toast.deleted': 'Token smazán',
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
'settings.account': 'Účet',
'settings.about': 'O aplikaci',
'settings.username': 'Uživatelské jméno',
'settings.email': 'E-mail',
'settings.role': 'Role',
@@ -167,7 +210,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
'settings.passwordMismatch': 'Hesla se neshodují',
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici',
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak',
'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
'settings.deleteAccount': 'Smazat účet',
'settings.deleteAccountTitle': 'Smazat váš účet?',
@@ -188,6 +231,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'Nahrávání se nezdařilo',
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.',
'settings.mfa.backupTitle': 'Záložní kódy',
'settings.mfa.backupDescription': 'Použijte tyto jednorázové kódy, pokud ztratíte přístup k autentizační aplikaci.',
'settings.mfa.backupWarning': 'Uložte si je hned. Každý kód lze použít pouze jednou.',
'settings.mfa.backupCopy': 'Kopírovat kódy',
'settings.mfa.backupDownload': 'Stáhnout TXT',
'settings.mfa.backupPrint': 'Tisk / PDF',
'settings.mfa.backupCopied': 'Záložní kódy zkopírovány',
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
'settings.mfa.disabled': '2FA není aktivní.',
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
@@ -202,9 +253,23 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
'admin.notifications.title': 'Oznámení',
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
'admin.notifications.none': 'Vypnuto',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Události oznámení',
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo',
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
'admin.notifications.testWebhookSuccess': 'Testovací webhook úspěšně odeslán',
'admin.notifications.testWebhookFailed': 'Odeslání testovacího webhooku se nezdařilo',
'admin.smtp.title': 'E-mail a oznámení',
'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.',
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
'admin.smtp.testButton': 'Odeslat testovací e-mail',
'admin.webhook.hint': 'Odesílat oznámení na externí webhook (Discord, Slack atd.).',
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
@@ -264,6 +329,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Přihlásit se',
'login.createAdmin': 'Vytvořit účet administrátora',
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
'login.setNewPassword': 'Nastavit nové heslo',
'login.setNewPasswordHint': 'Před pokračováním musíte změnit heslo.',
'login.createAccount': 'Vytvořit účet',
'login.createAccountHint': 'Zaregistrujte si nový účet.',
'login.creating': 'Vytváření…',
@@ -290,7 +357,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují',
'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků',
'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
'register.failed': 'Registrace se nezdařila',
'register.getStarted': 'Začínáme',
'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',
@@ -365,6 +432,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Nastavení',
'admin.allowRegistration': 'Povolit registraci',
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
'admin.apiKeys': 'API klíče',
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
'admin.mapsKey': 'Google Maps API klíč',
@@ -419,8 +488,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Doplňky',
'admin.addons.title': 'Doplňky',
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
'admin.addons.catalog.memories.name': 'Vzpomínky',
'admin.addons.catalog.memories.description': 'Sdílená fotoalba pro každou cestu',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'admin.addons.catalog.packing.name': 'Balení',
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
'admin.addons.catalog.budget.name': 'Rozpočet',
@@ -437,13 +506,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.disabled': 'Zakázáno',
'admin.addons.type.trip': 'Cesta',
'admin.addons.type.global': 'Globální',
'admin.addons.type.integration': 'Integrace',
'admin.addons.tripHint': 'Dostupné jako karta v rámci každé cesty',
'admin.addons.globalHint': 'Dostupné jako samostatná sekce v hlavní navigaci',
'admin.addons.integrationHint': 'Backendové služby a API integrace bez vlastní stránky',
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů',
'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
'admin.addons.subtitleAfter': '.',
@@ -461,6 +532,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Detaily',
// MCP Tokens
'admin.tabs.mcpTokens': 'MCP tokeny',
'admin.mcpTokens.title': 'MCP tokeny',
'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů',
'admin.mcpTokens.owner': 'Vlastník',
'admin.mcpTokens.tokenName': 'Název tokenu',
'admin.mcpTokens.created': 'Vytvořen',
'admin.mcpTokens.lastUsed': 'Naposledy použit',
'admin.mcpTokens.never': 'Nikdy',
'admin.mcpTokens.empty': 'Zatím nebyly vytvořeny žádné MCP tokeny',
'admin.mcpTokens.deleteTitle': 'Smazat token',
'admin.mcpTokens.deleteMessage': 'Tento token bude okamžitě zneplatněn. Uživatel ztratí MCP přístup přes tento token.',
'admin.mcpTokens.deleteSuccess': 'Token smazán',
'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Historie verzí',
@@ -510,7 +597,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení',
'vacay.year': 'Rok',
'vacay.addYear': 'Přidat rok',
'vacay.addYear': 'Přidat následující rok',
'vacay.addPrevYear': 'Přidat předchozí rok',
'vacay.removeYear': 'Odebrat rok',
'vacay.removeYearConfirm': 'Odebrat rok {year}?',
'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',
@@ -612,7 +700,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Statistiky',
'atlas.bucketTab': 'Bucket List',
'atlas.addBucket': 'Přidat na Bucket List',
'atlas.bucketNamePlaceholder': 'Místo nebo destinace...',
'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)',
'atlas.bucketEmpty': 'Váš seznam přání je prázdný',
'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit',
@@ -655,6 +742,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
'trip.loadingPhotos': 'Načítání fotek míst...',
'trip.mobilePlan': 'Plán',
'trip.mobilePlaces': 'Místa',
'trip.toast.placeUpdated': 'Místo bylo aktualizováno',
@@ -701,10 +789,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Boční panel míst (Places Sidebar)
'places.addPlace': 'Přidat místo/aktivitu',
'places.importGpx': 'Importovat GPX',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} míst importováno z GPX',
'places.urlResolved': 'Místo importováno z URL',
'places.gpxError': 'Import GPX se nezdařil',
'places.importGoogleList': 'Google Seznam',
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
'places.viewDetails': 'Zobrazit detaily',
'places.assignToDay': 'Přidat do kterého dne?',
'places.all': 'Vše',
'places.unplanned': 'Nezařazené',
@@ -761,6 +854,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Rezervace',
'inspector.editRes': 'Upravit rezervaci',
'inspector.participants': 'Účastníci',
'inspector.trackStats': 'Data trasy',
// Rezervace (Reservations)
'reservations.title': 'Rezervace',
@@ -843,6 +937,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Rozpočet (Budget)
'budget.title': 'Rozpočet',
'budget.exportCsv': 'Exportovat CSV',
'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet',
'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu',
'budget.emptyPlaceholder': 'Zadejte název kategorie...',
@@ -857,6 +952,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'Za den',
'budget.table.perPersonDay': 'Os. / den',
'budget.table.note': 'Poznámka',
'budget.table.date': 'Datum',
'budget.newEntry': 'Nová položka',
'budget.defaultEntry': 'Nová položka',
'budget.defaultCategory': 'Nová kategorie',
@@ -1250,12 +1346,19 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.immichUrl': 'URL serveru Immich',
'memories.immichApiKey': 'API klíč',
'memories.testConnection': 'Otestovat připojení',
'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno',
'memories.connectionSuccess': 'Připojeno k Immich',
'memories.connectionError': 'Nepodařilo se připojit k Immich',
'memories.saved': 'Nastavení Immich uloženo',
'memories.addPhotos': 'Přidat fotky',
'memories.linkAlbum': 'Propojit album',
'memories.selectAlbum': 'Vybrat album z Immich',
'memories.noAlbums': 'Žádná alba nenalezena',
'memories.syncAlbum': 'Synchronizovat album',
'memories.unlinkAlbum': 'Odpojit',
'memories.photos': 'fotek',
'memories.selectPhotos': 'Vybrat fotky z Immich',
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
'memories.selected': 'vybráno',
@@ -1290,6 +1393,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'Dnes',
'collab.chat.yesterday': 'Včera',
'collab.chat.deletedMessage': 'smazal zprávu',
'collab.chat.reply': 'Odpovědět',
'collab.chat.loadMore': 'Načíst starší zprávy',
'collab.chat.justNow': 'právě teď',
'collab.chat.minutesAgo': 'před {n} min',
@@ -1340,6 +1444,106 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'Možnosti',
'collab.polls.delete': 'Smazat',
'collab.polls.closedSection': 'Uzavřené',
// Permissions
'admin.tabs.permissions': 'Oprávnění',
'perm.title': 'Nastavení oprávnění',
'perm.subtitle': 'Určete, kdo může provádět akce v aplikaci',
'perm.saved': 'Nastavení oprávnění uloženo',
'perm.resetDefaults': 'Obnovit výchozí',
'perm.customized': 'upraveno',
'perm.level.admin': 'Pouze administrátor',
'perm.level.tripOwner': 'Vlastník výletu',
'perm.level.tripMember': 'Členové výletu',
'perm.level.everybody': 'Všichni',
'perm.cat.trip': 'Správa výletů',
'perm.cat.members': 'Správa členů',
'perm.cat.files': 'Soubory',
'perm.cat.content': 'Obsah a plán',
'perm.cat.extras': 'Rozpočet, balení a spolupráce',
'perm.action.trip_create': 'Vytvářet výlety',
'perm.action.trip_edit': 'Upravit detaily výletu',
'perm.action.trip_delete': 'Smazat výlety',
'perm.action.trip_archive': 'Archivovat / odarchivovat výlety',
'perm.action.trip_cover_upload': 'Nahrát titulní obrázek',
'perm.action.member_manage': 'Přidat / odebrat členy',
'perm.action.file_upload': 'Nahrát soubory',
'perm.action.file_edit': 'Upravit metadata souborů',
'perm.action.file_delete': 'Smazat soubory',
'perm.action.place_edit': 'Přidat / upravit / smazat místa',
'perm.action.day_edit': 'Upravit dny, poznámky a přiřazení',
'perm.action.reservation_edit': 'Spravovat rezervace',
'perm.action.budget_edit': 'Spravovat rozpočet',
'perm.action.packing_edit': 'Spravovat seznamy balení',
'perm.action.collab_edit': 'Spolupráce (poznámky, hlasování, chat)',
'perm.action.share_manage': 'Spravovat odkazy ke sdílení',
'perm.actionHint.trip_create': 'Kdo může vytvářet nové výlety',
'perm.actionHint.trip_edit': 'Kdo může měnit název, data, popis a měnu výletu',
'perm.actionHint.trip_delete': 'Kdo může trvale smazat výlet',
'perm.actionHint.trip_archive': 'Kdo může archivovat nebo odarchivovat výlet',
'perm.actionHint.trip_cover_upload': 'Kdo může nahrát nebo změnit titulní obrázek',
'perm.actionHint.member_manage': 'Kdo může pozvat nebo odebrat členy výletu',
'perm.actionHint.file_upload': 'Kdo může nahrávat soubory k výletu',
'perm.actionHint.file_edit': 'Kdo může upravovat popisy a odkazy souborů',
'perm.actionHint.file_delete': 'Kdo může přesunout soubory do koše nebo je trvale smazat',
'perm.actionHint.place_edit': 'Kdo může přidávat, upravovat nebo mazat místa',
'perm.actionHint.day_edit': 'Kdo může upravovat dny, poznámky ke dnům a přiřazení míst',
'perm.actionHint.reservation_edit': 'Kdo může vytvářet, upravovat nebo mazat rezervace',
'perm.actionHint.budget_edit': 'Kdo může vytvářet, upravovat nebo mazat položky rozpočtu',
'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky',
'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
// Undo
'undo.button': 'Zpět',
'undo.tooltip': 'Zpět: {action}',
'undo.assignPlace': 'Místo přiřazeno ke dni',
'undo.removeAssignment': 'Místo odebráno ze dne',
'undo.reorder': 'Místa přeseřazena',
'undo.optimize': 'Trasa optimalizována',
'undo.deletePlace': 'Místo smazáno',
'undo.moveDay': 'Místo přesunuto na jiný den',
'undo.lock': 'Zámek místa přepnut',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import z Google Maps',
// Notifications
'notifications.title': 'Oznámení',
'notifications.markAllRead': 'Označit vše jako přečtené',
'notifications.deleteAll': 'Smazat vše',
'notifications.showAll': 'Zobrazit všechna oznámení',
'notifications.empty': 'Žádná oznámení',
'notifications.emptyDescription': 'Vše máte přečteno!',
'notifications.all': 'Vše',
'notifications.unreadOnly': 'Nepřečtené',
'notifications.markRead': 'Označit jako přečtené',
'notifications.markUnread': 'Označit jako nepřečtené',
'notifications.delete': 'Smazat',
'notifications.system': 'Systém',
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
'atlas.searchCountry': 'Hledat zemi...',
'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
'memories.error.linkAlbum': 'Propojení alba se nezdařilo',
'memories.error.unlinkAlbum': 'Odpojení alba se nezdařilo',
'memories.error.syncAlbum': 'Synchronizace alba se nezdařila',
'memories.error.loadPhotos': 'Načtení fotek se nezdařilo',
'memories.error.addPhotos': 'Přidání fotek se nezdařilo',
'memories.error.removePhoto': 'Odebrání fotky se nezdařilo',
'memories.error.toggleSharing': 'Aktualizace sdílení se nezdařila',
'undo.addPlace': 'Místo přidáno',
'undo.done': 'Vráceno zpět: {action}',
'notifications.test.title': 'Testovací oznámení od {actor}',
'notifications.test.text': 'Toto je jednoduché testovací oznámení.',
'notifications.test.booleanTitle': '{actor} žádá o vaše schválení',
'notifications.test.booleanText': 'Testovací oznámení s volbou.',
'notifications.test.accept': 'Schválit',
'notifications.test.decline': 'Odmítnout',
'notifications.test.navigateTitle': 'Podívejte se na toto',
'notifications.test.navigateText': 'Testovací navigační oznámení.',
'notifications.test.goThere': 'Přejít tam',
'notifications.test.adminTitle': 'Hromadná zpráva pro správce',
'notifications.test.adminText': '{actor} odeslal testovací oznámení všem správcům.',
'notifications.test.tripTitle': '{actor} přispěl do vašeho výletu',
'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".',
}
export default cs
export default cs
File diff suppressed because it is too large Load Diff
+213 -7
View File
@@ -6,6 +6,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'Edit',
'common.add': 'Add',
'common.loading': 'Loading...',
'common.import': 'Import',
'common.error': 'Error',
'common.back': 'Back',
'common.all': 'All',
@@ -25,6 +26,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email',
'common.password': 'Password',
'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.change': 'Change',
'common.uploading': 'Uploading…',
@@ -71,7 +80,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Shared by {name}',
'dashboard.days': 'Days',
'dashboard.places': 'Places',
'dashboard.members': 'Buddies',
'dashboard.archive': 'Archive',
'dashboard.copyTrip': 'Copy',
'dashboard.copySuffix': 'copy',
'dashboard.restore': 'Restore',
'dashboard.archived': 'Archived',
'dashboard.status.ongoing': 'Ongoing',
@@ -90,6 +102,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Failed to archive trip',
'dashboard.toast.restored': 'Trip restored',
'dashboard.toast.restoreError': 'Failed to restore trip',
'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
@@ -149,11 +163,28 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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 email notifications. Optional: Webhook URL for Discord, Slack, etc.',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
'admin.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.',
@@ -185,7 +216,33 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'share.permCollab': 'Chat',
'settings.on': 'On',
'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.about': 'About',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Role',
@@ -200,8 +257,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Please enter current and new password',
'settings.passwordTooShort': 'Password must be at least 8 characters',
'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.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
'settings.deleteAccount': 'Delete 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.',
@@ -221,6 +279,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'Upload failed',
'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.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.disabled': '2FA is not enabled.',
'settings.mfa.setup': 'Set up authenticator',
@@ -263,6 +329,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account',
'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.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',
@@ -289,7 +357,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Register
'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.getStarted': 'Get Started',
'register.subtitle': 'Create an account and start planning your dream trips.',
@@ -365,6 +433,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'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.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
'admin.mapsKey': 'Google Maps API Key',
@@ -433,14 +503,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
'admin.addons.catalog.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.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
'admin.addons.disabled': 'Disabled',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
'admin.addons.type.integration': 'Integration',
'admin.addons.tripHint': 'Available as a tab within each trip',
'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.error': 'Failed to update addon',
'admin.addons.noAddons': 'No addons available',
@@ -457,6 +531,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
// 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.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
@@ -504,7 +592,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
'vacay.addYear': 'Add year',
'vacay.addYear': 'Add next year',
'vacay.addPrevYear': 'Add previous year',
'vacay.removeYear': 'Remove year',
'vacay.removeYearConfirm': 'Remove {year}?',
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
@@ -600,6 +689,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'atlas.markVisitedHint': 'Add this country to your visited 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',
@@ -608,7 +698,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Stats',
'atlas.bucketTab': 'Bucket List',
'atlas.addBucket': 'Add to bucket list',
'atlas.bucketNamePlaceholder': 'Place or destination...',
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
'atlas.bucketEmpty': 'Your bucket list is empty',
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
@@ -621,7 +710,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'atlas.nextTrip': 'Next trip',
'atlas.daysLeft': 'days left',
'atlas.streak': 'Streak',
'atlas.year': 'year',
'atlas.years': 'years',
'atlas.yearInRow': 'year in a row',
'atlas.yearsInRow': 'years in a row',
@@ -651,6 +739,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
'trip.loadingPhotos': 'Loading place photos...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Places',
'trip.toast.placeUpdated': 'Place updated',
@@ -697,10 +786,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Add Place/Activity',
'places.importGpx': 'Import GPX',
'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.all': 'All',
'places.unplanned': 'Unplanned',
@@ -756,6 +850,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reservation',
'inspector.editRes': 'Edit Reservation',
'inspector.participants': 'Participants',
'inspector.trackStats': 'Track Stats',
// Reservations
'reservations.title': 'Bookings',
@@ -838,6 +933,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Budget
'budget.title': 'Budget',
'budget.exportCsv': 'Export CSV',
'budget.emptyTitle': 'No budget created yet',
'budget.emptyText': 'Create categories and entries to plan your travel budget',
'budget.emptyPlaceholder': 'Enter category name...',
@@ -852,6 +948,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'Per Day',
'budget.table.perPersonDay': 'P. p / Day',
'budget.table.note': 'Note',
'budget.table.date': 'Date',
'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry',
'budget.defaultCategory': 'New Category',
@@ -1245,12 +1342,19 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1266,6 +1370,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
'memories.error.loadAlbums': 'Failed to load albums',
'memories.error.linkAlbum': 'Failed to link album',
'memories.error.unlinkAlbum': 'Failed to unlink album',
'memories.error.syncAlbum': 'Failed to sync album',
'memories.error.loadPhotos': 'Failed to load photos',
'memories.error.addPhotos': 'Failed to add photos',
'memories.error.removePhoto': 'Failed to remove photo',
'memories.error.toggleSharing': 'Failed to update sharing',
// Collab Addon
'collab.tabs.chat': 'Chat',
@@ -1285,6 +1397,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'Today',
'collab.chat.yesterday': 'Yesterday',
'collab.chat.deletedMessage': 'deleted a message',
'collab.chat.reply': 'Reply',
'collab.chat.loadMore': 'Load older messages',
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
@@ -1335,6 +1448,99 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'Options',
'collab.polls.delete': 'Delete',
'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',
// Undo
'undo.button': 'Undo',
'undo.tooltip': 'Undo: {action}',
'undo.assignPlace': 'Place assigned to day',
'undo.removeAssignment': 'Place removed from day',
'undo.reorder': 'Places reordered',
'undo.optimize': 'Route optimized',
'undo.deletePlace': 'Place deleted',
'undo.moveDay': 'Place moved to another day',
'undo.lock': 'Place lock toggled',
'undo.importGpx': 'GPX import',
'undo.importGoogleList': 'Google Maps import',
'undo.addPlace': 'Place added',
'undo.done': 'Undone: {action}',
// Notifications
'notifications.title': 'Notifications',
'notifications.markAllRead': 'Mark all read',
'notifications.deleteAll': 'Delete all',
'notifications.showAll': 'Show all notifications',
'notifications.empty': 'No notifications',
'notifications.emptyDescription': "You're all caught up!",
'notifications.all': 'All',
'notifications.unreadOnly': 'Unread',
'notifications.markRead': 'Mark as read',
'notifications.markUnread': 'Mark as unread',
'notifications.delete': 'Delete',
'notifications.system': 'System',
// Notification test keys (dev only)
'notifications.test.title': 'Test notification from {actor}',
'notifications.test.text': 'This is a simple test notification.',
'notifications.test.booleanTitle': '{actor} asks for your approval',
'notifications.test.booleanText': 'This is a test boolean notification. Choose an action below.',
'notifications.test.accept': 'Approve',
'notifications.test.decline': 'Decline',
'notifications.test.navigateTitle': 'Check something out',
'notifications.test.navigateText': 'This is a test navigate notification.',
'notifications.test.goThere': 'Go there',
'notifications.test.adminTitle': 'Admin broadcast',
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
'notifications.test.tripTitle': '{actor} posted in your trip',
'notifications.test.tripText': 'Test notification for trip "{trip}".',
}
export default en
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+213 -7
View File
@@ -6,6 +6,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
'common.loading': 'Caricamento...',
'common.import': 'Importa',
'common.error': 'Errore',
'common.back': 'Indietro',
'common.all': 'Tutti',
@@ -25,6 +26,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Salvataggio...',
'common.saved': 'Salvato',
'trips.reminder': 'Promemoria',
'trips.reminderNone': 'Nessuno',
'trips.reminderDay': 'giorno',
'trips.reminderDays': 'giorni',
'trips.reminderCustom': 'Personalizzato',
'trips.reminderDaysBefore': 'giorni prima della partenza',
'trips.reminderDisabledHint': 'I promemoria dei viaggi sono disabilitati. Abilitali in Admin > Impostazioni > Notifiche.',
'common.update': 'Aggiorna',
'common.change': 'Cambia',
'common.uploading': 'Caricamento…',
@@ -71,7 +80,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Condiviso da {name}',
'dashboard.days': 'Giorni',
'dashboard.places': 'Luoghi',
'dashboard.members': 'Compagni di viaggio',
'dashboard.archive': 'Archivia',
'dashboard.copyTrip': 'Copia',
'dashboard.copySuffix': 'copia',
'dashboard.restore': 'Ripristina',
'dashboard.archived': 'Archiviati',
'dashboard.status.ongoing': 'In corso',
@@ -90,6 +102,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio',
'dashboard.toast.restored': 'Viaggio ripristinato',
'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio',
'dashboard.toast.copied': 'Viaggio copiato!',
'dashboard.toast.copyError': 'Impossibile copiare il viaggio',
'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.',
'dashboard.editTrip': 'Modifica Viaggio',
'dashboard.createTrip': 'Crea Nuovo Viaggio',
@@ -149,9 +163,38 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
'settings.notifyWebhook': 'Notifiche webhook',
'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.',
'settings.notificationsActive': 'Canale attivo',
'settings.notificationsManagedByAdmin': 'Gli eventi di notifica sono configurati dall\'amministratore.',
'settings.on': 'On',
'settings.off': 'Off',
'settings.mcp.title': 'Configurazione MCP',
'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configurazione client',
'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
'settings.mcp.copy': 'Copia',
'settings.mcp.copied': 'Copiato!',
'settings.mcp.apiTokens': 'Token API',
'settings.mcp.createToken': 'Crea nuovo token',
'settings.mcp.noTokens': 'Nessun token ancora. Creane uno per connettere i client MCP.',
'settings.mcp.tokenCreatedAt': 'Creato',
'settings.mcp.tokenUsedAt': 'Utilizzato',
'settings.mcp.deleteTokenTitle': 'Elimina token',
'settings.mcp.deleteTokenMessage': 'Questo token smetterà di funzionare immediatamente. Qualsiasi client MCP che lo utilizza perderà l\'accesso.',
'settings.mcp.modal.createTitle': 'Crea token API',
'settings.mcp.modal.tokenName': 'Nome del token',
'settings.mcp.modal.tokenNamePlaceholder': 'es. Claude Desktop, Laptop di lavoro',
'settings.mcp.modal.creating': 'Creazione…',
'settings.mcp.modal.create': 'Crea token',
'settings.mcp.modal.createdTitle': 'Token creato',
'settings.mcp.modal.createdWarning': 'Questo token verrà mostrato solo una volta. Copialo e salvalo ora — non può essere recuperato.',
'settings.mcp.modal.done': 'Fatto',
'settings.mcp.toast.created': 'Token creato',
'settings.mcp.toast.createError': 'Impossibile creare il token',
'settings.mcp.toast.deleted': 'Token eliminato',
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
'settings.account': 'Account',
'settings.about': 'Informazioni',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Ruolo',
@@ -166,7 +209,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
'settings.passwordMismatch': 'Le password non corrispondono',
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero',
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale',
'settings.passwordChanged': 'Password cambiata con successo',
'settings.deleteAccount': 'Elimina account',
'settings.deleteAccountTitle': 'Eliminare il tuo account?',
@@ -187,6 +230,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'Impossibile caricare',
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.',
'settings.mfa.backupTitle': 'Codici di backup',
'settings.mfa.backupDescription': 'Usa questi codici monouso se perdi l\'accesso alla tua app authenticator.',
'settings.mfa.backupWarning': 'Salvali adesso. Ogni codice può essere usato una sola volta.',
'settings.mfa.backupCopy': 'Copia codici',
'settings.mfa.backupDownload': 'Scarica TXT',
'settings.mfa.backupPrint': 'Stampa / PDF',
'settings.mfa.backupCopied': 'Codici di backup copiati',
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
'settings.mfa.disabled': 'La 2FA non è abilitata.',
'settings.mfa.setup': 'Configura authenticator',
@@ -201,9 +252,24 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.',
'admin.notifications.title': 'Notifiche',
'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.',
'admin.notifications.none': 'Disattivato',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventi di notifica',
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
'admin.notifications.save': 'Salva impostazioni notifiche',
'admin.notifications.saved': 'Impostazioni notifiche salvate',
'admin.notifications.testWebhook': 'Invia webhook di test',
'admin.notifications.testWebhookSuccess': 'Webhook di test inviato con successo',
'admin.notifications.testWebhookFailed': 'Invio webhook di test fallito',
'admin.smtp.title': 'Email e notifiche',
'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.',
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
'admin.smtp.testButton': 'Invia email di prova',
'admin.webhook.hint': 'Invia notifiche a un webhook esterno (Discord, Slack, ecc.).',
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
'admin.smtp.testFailed': 'Invio email di prova fallito',
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
@@ -263,6 +329,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Accedi',
'login.createAdmin': 'Crea Account Amministratore',
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
'login.setNewPassword': 'Imposta nuova password',
'login.setNewPasswordHint': 'Devi cambiare la password prima di continuare.',
'login.createAccount': 'Crea Account',
'login.createAccountHint': 'Registra un nuovo account.',
'login.creating': 'Creazione in corso…',
@@ -289,7 +357,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'Le password non corrispondono',
'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri',
'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
'register.failed': 'Registrazione fallita',
'register.getStarted': 'Inizia',
'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',
@@ -364,6 +432,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Impostazioni',
'admin.allowRegistration': 'Consenti Registrazione',
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
'admin.apiKeys': 'Chiavi API',
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
'admin.mapsKey': 'Chiave API Google Maps',
@@ -431,14 +501,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.collab.description': 'Note, sondaggi e chat in tempo reale per la pianificazione del viaggio',
'admin.addons.catalog.memories.name': 'Foto (Immich)',
'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol per l\'integrazione di assistenti AI',
'admin.addons.subtitleBefore': 'Abilita o disabilita le funzionalità per personalizzare la tua ',
'admin.addons.subtitleAfter': ' esperienza.',
'admin.addons.enabled': 'Abilitato',
'admin.addons.disabled': 'Disabilitato',
'admin.addons.type.trip': 'Viaggio',
'admin.addons.type.global': 'Globale',
'admin.addons.type.integration': 'Integrazione',
'admin.addons.tripHint': 'Disponibile come scheda all\'interno di ciascun viaggio',
'admin.addons.globalHint': 'Disponibile come sezione autonoma nella navigazione principale',
'admin.addons.integrationHint': 'Servizi backend e integrazioni API senza pagina dedicata',
'admin.addons.toast.updated': 'Modulo aggiornato',
'admin.addons.toast.error': 'Impossibile aggiornare il modulo',
'admin.addons.noAddons': 'Nessun modulo disponibile',
@@ -469,6 +543,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Dettagli',
// MCP Tokens
'admin.tabs.mcpTokens': 'Token MCP',
'admin.mcpTokens.title': 'Token MCP',
'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti',
'admin.mcpTokens.owner': 'Proprietario',
'admin.mcpTokens.tokenName': 'Nome token',
'admin.mcpTokens.created': 'Creato',
'admin.mcpTokens.lastUsed': 'Ultimo utilizzo',
'admin.mcpTokens.never': 'Mai',
'admin.mcpTokens.empty': 'Non sono ancora stati creati token MCP',
'admin.mcpTokens.deleteTitle': 'Elimina token',
'admin.mcpTokens.deleteMessage': 'Questo token verrà revocato immediatamente. L\'utente perderà l\'accesso MCP tramite questo token.',
'admin.mcpTokens.deleteSuccess': 'Token eliminato',
'admin.mcpTokens.deleteError': 'Impossibile eliminare il token',
'admin.mcpTokens.loadError': 'Impossibile caricare i token',
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Cronologia rilasci',
@@ -505,7 +595,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno',
'vacay.addYear': 'Aggiungi anno',
'vacay.addYear': 'Aggiungi anno successivo',
'vacay.addPrevYear': 'Aggiungi anno precedente',
'vacay.removeYear': 'Rimuovi anno',
'vacay.removeYearConfirm': 'Rimuovere {year}?',
'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',
@@ -608,7 +699,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Statistiche',
'atlas.bucketTab': 'Lista desideri',
'atlas.addBucket': 'Aggiungi alla lista desideri',
'atlas.bucketNamePlaceholder': 'Luogo o destinazione...',
'atlas.bucketNotesPlaceholder': 'Note (opzionale)',
'atlas.bucketEmpty': 'La tua lista desideri è vuota',
'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare',
@@ -641,6 +731,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'atlas.tripPlural': 'Viaggi',
'atlas.placeVisited': 'Luogo visitato',
'atlas.placesVisited': 'Luoghi visitati',
'atlas.searchCountry': 'Cerca un paese...',
// Trip Planner
'trip.tabs.plan': 'Programma',
@@ -663,6 +754,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
'trip.toast.deleted': 'Eliminato',
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
// Day Plan Sidebar
'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno',
@@ -697,10 +789,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Aggiungi Luogo/Attività',
'places.importGpx': 'Importa GPX',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} luoghi importati da GPX',
'places.urlResolved': 'Luogo importato dall\'URL',
'places.gpxError': 'Importazione GPX non riuscita',
'places.importGoogleList': 'Lista Google',
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
'places.googleListImported': '{count} luoghi importati da "{list}"',
'places.googleListError': 'Importazione lista Google Maps non riuscita',
'places.viewDetails': 'Visualizza dettagli',
'places.assignToDay': 'A quale giorno aggiungere?',
'places.all': 'Tutti',
'places.unplanned': 'Non pianificati',
@@ -756,6 +853,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Prenotazione',
'inspector.editRes': 'Modifica prenotazione',
'inspector.participants': 'Partecipanti',
'inspector.trackStats': 'Dati del percorso',
// Reservations
'reservations.title': 'Prenotazioni',
@@ -838,6 +936,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Budget
'budget.title': 'Budget',
'budget.exportCsv': 'Esporta CSV',
'budget.emptyTitle': 'Ancora nessun budget creato',
'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio',
'budget.emptyPlaceholder': 'Inserisci nome categoria...',
@@ -852,6 +951,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'Per giorno',
'budget.table.perPersonDay': 'P. p / gio.',
'budget.table.note': 'Nota',
'budget.table.date': 'Data',
'budget.newEntry': 'Nuova voce',
'budget.defaultEntry': 'Nuova voce',
'budget.defaultCategory': 'Nuova categoria',
@@ -1245,12 +1345,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.immichUrl': 'URL Server Immich',
'memories.immichApiKey': 'Chiave API',
'memories.testConnection': 'Test connessione',
'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso',
'memories.connectionSuccess': 'Connesso a Immich',
'memories.connectionError': 'Impossibile connettersi a Immich',
'memories.saved': 'Impostazioni Immich salvate',
'memories.addPhotos': 'Aggiungi foto',
'memories.linkAlbum': 'Collega album',
'memories.selectAlbum': 'Seleziona album Immich',
'memories.noAlbums': 'Nessun album trovato',
'memories.syncAlbum': 'Sincronizza album',
'memories.unlinkAlbum': 'Scollega',
'memories.photos': 'foto',
'memories.selectPhotos': 'Seleziona foto da Immich',
'memories.selectHint': 'Tocca le foto per selezionarle.',
'memories.selected': 'selezionate',
@@ -1285,6 +1392,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'Oggi',
'collab.chat.yesterday': 'Ieri',
'collab.chat.deletedMessage': 'ha eliminato un messaggio',
'collab.chat.reply': 'Rispondi',
'collab.chat.loadMore': 'Carica messaggi precedenti',
'collab.chat.justNow': 'ora',
'collab.chat.minutesAgo': '{n}m fa',
@@ -1335,6 +1443,104 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'Opzioni',
'collab.polls.delete': 'Elimina',
'collab.polls.closedSection': 'Chiusi',
// Permissions
'admin.tabs.permissions': 'Permessi',
'perm.title': 'Impostazioni dei permessi',
'perm.subtitle': 'Controlla chi può eseguire azioni nell\'applicazione',
'perm.saved': 'Impostazioni dei permessi salvate',
'perm.resetDefaults': 'Ripristina predefiniti',
'perm.customized': 'personalizzato',
'perm.level.admin': 'Solo amministratore',
'perm.level.tripOwner': 'Proprietario del viaggio',
'perm.level.tripMember': 'Membri del viaggio',
'perm.level.everybody': 'Tutti',
'perm.cat.trip': 'Gestione viaggi',
'perm.cat.members': 'Gestione membri',
'perm.cat.files': 'File',
'perm.cat.content': 'Contenuti e programma',
'perm.cat.extras': 'Budget, bagagli e collaborazione',
'perm.action.trip_create': 'Creare viaggi',
'perm.action.trip_edit': 'Modificare dettagli del viaggio',
'perm.action.trip_delete': 'Eliminare viaggi',
'perm.action.trip_archive': 'Archiviare / dearchiviare viaggi',
'perm.action.trip_cover_upload': 'Caricare immagine di copertina',
'perm.action.member_manage': 'Aggiungere / rimuovere membri',
'perm.action.file_upload': 'Caricare file',
'perm.action.file_edit': 'Modificare metadati dei file',
'perm.action.file_delete': 'Eliminare file',
'perm.action.place_edit': 'Aggiungere / modificare / eliminare luoghi',
'perm.action.day_edit': 'Modificare giorni, note e assegnazioni',
'perm.action.reservation_edit': 'Gestire prenotazioni',
'perm.action.budget_edit': 'Gestire budget',
'perm.action.packing_edit': 'Gestire liste bagagli',
'perm.action.collab_edit': 'Collaborazione (note, sondaggi, chat)',
'perm.action.share_manage': 'Gestire link di condivisione',
'perm.actionHint.trip_create': 'Chi può creare nuovi viaggi',
'perm.actionHint.trip_edit': 'Chi può modificare nome, date, descrizione e valuta del viaggio',
'perm.actionHint.trip_delete': 'Chi può eliminare definitivamente un viaggio',
'perm.actionHint.trip_archive': 'Chi può archiviare o dearchiviare un viaggio',
'perm.actionHint.trip_cover_upload': 'Chi può caricare o modificare l\'immagine di copertina',
'perm.actionHint.member_manage': 'Chi può invitare o rimuovere membri del viaggio',
'perm.actionHint.file_upload': 'Chi può caricare file in un viaggio',
'perm.actionHint.file_edit': 'Chi può modificare descrizioni e link dei file',
'perm.actionHint.file_delete': 'Chi può spostare file nel cestino o eliminarli definitivamente',
'perm.actionHint.place_edit': 'Chi può aggiungere, modificare o eliminare luoghi',
'perm.actionHint.day_edit': 'Chi può modificare giorni, note dei giorni e assegnazioni dei luoghi',
'perm.actionHint.reservation_edit': 'Chi può creare, modificare o eliminare prenotazioni',
'perm.actionHint.budget_edit': 'Chi può creare, modificare o eliminare voci di budget',
'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse',
'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi',
'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
// Undo
'undo.button': 'Annulla',
'undo.tooltip': 'Annulla: {action}',
'undo.assignPlace': 'Luogo assegnato al giorno',
'undo.removeAssignment': 'Luogo rimosso dal giorno',
'undo.reorder': 'Luoghi riordinati',
'undo.optimize': 'Percorso ottimizzato',
'undo.deletePlace': 'Luogo eliminato',
'undo.moveDay': 'Luogo spostato in altro giorno',
'undo.lock': 'Blocco luogo modificato',
'undo.importGpx': 'Importazione GPX',
'undo.importGoogleList': 'Importazione Google Maps',
'undo.addPlace': 'Luogo aggiunto',
'undo.done': 'Annullato: {action}',
// Notifications
'notifications.title': 'Notifiche',
'notifications.markAllRead': 'Segna tutto come letto',
'notifications.deleteAll': 'Elimina tutto',
'notifications.showAll': 'Vedi tutte le notifiche',
'notifications.empty': 'Nessuna notifica',
'notifications.emptyDescription': 'Sei aggiornato!',
'notifications.all': 'Tutte',
'notifications.unreadOnly': 'Non lette',
'notifications.markRead': 'Segna come letto',
'notifications.markUnread': 'Segna come non letto',
'notifications.delete': 'Elimina',
'notifications.system': 'Sistema',
'memories.error.loadAlbums': 'Caricamento album non riuscito',
'memories.error.linkAlbum': 'Collegamento album non riuscito',
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
'memories.error.syncAlbum': 'Sincronizzazione album non riuscita',
'memories.error.loadPhotos': 'Caricamento foto non riuscito',
'memories.error.addPhotos': 'Aggiunta foto non riuscita',
'memories.error.removePhoto': 'Rimozione foto non riuscita',
'memories.error.toggleSharing': 'Aggiornamento condivisione non riuscito',
'notifications.test.title': 'Notifica di test da {actor}',
'notifications.test.text': 'Questa è una semplice notifica di test.',
'notifications.test.booleanTitle': '{actor} richiede la tua approvazione',
'notifications.test.booleanText': 'Notifica di test con risposta.',
'notifications.test.accept': 'Approva',
'notifications.test.decline': 'Rifiuta',
'notifications.test.navigateTitle': 'Dai un\'occhiata',
'notifications.test.navigateText': 'Notifica di test con navigazione.',
'notifications.test.goThere': 'Vai',
'notifications.test.adminTitle': 'Comunicazione admin',
'notifications.test.adminText': '{actor} ha inviato una notifica di test a tutti gli amministratori.',
'notifications.test.tripTitle': '{actor} ha pubblicato nel tuo viaggio',
'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".',
}
export default it
export default it
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+401 -234
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
@@ -14,7 +16,9 @@ import GitHubPanel from '../components/Admin/GitHubPanel'
import AddonManager from '../components/Admin/AddonManager'
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
interface AdminUser {
@@ -42,6 +46,7 @@ interface OidcConfig {
client_secret_set: boolean
display_name: string
oidc_only: boolean
discovery_url: string
}
interface UpdateInfo {
@@ -56,6 +61,8 @@ export default function AdminPage(): React.ReactElement {
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'config', label: t('admin.tabs.config') },
@@ -63,7 +70,9 @@ export default function AdminPage(): React.ReactElement {
{ id: 'settings', label: t('admin.tabs.settings') },
{ 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') },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
]
const [activeTab, setActiveTab] = useState<string>('users')
@@ -80,11 +89,12 @@ export default function AdminPage(): React.ReactElement {
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// 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)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
@@ -116,13 +126,14 @@ export default function AdminPage(): React.ReactElement {
// Version check & update
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
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 toast = useToast()
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
useEffect(() => {
loadData()
loadAppConfig()
@@ -155,6 +166,7 @@ export default function AdminPage(): React.ReactElement {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
@@ -171,26 +183,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) => {
setAllowRegistration(value)
try {
@@ -201,6 +193,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) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
@@ -253,6 +257,10 @@ export default function AdminPage(): React.ReactElement {
toast.error(t('admin.toast.fieldsRequired'))
return
}
if (createForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
@@ -308,7 +316,13 @@ export default function AdminPage(): React.ReactElement {
email: editForm.email.trim() || undefined,
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)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
@@ -376,23 +390,13 @@ export default function AdminPage(): React.ReactElement {
{t('admin.update.button')}
</a>
)}
{updateInfo.is_docker ? (
<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.howTo')}
</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>
)}
<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.howTo')}
</button>
</div>
</div>
)}
@@ -618,6 +622,8 @@ export default function AdminPage(): React.ReactElement {
</div>
)}
{activeTab === 'users' && <div className="mt-6"><PermissionsPanel /></div>}
{/* Create Invite Modal */}
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
<div className="space-y-4">
@@ -692,14 +698,38 @@ export default function AdminPage(): React.ReactElement {
</div>
<button
onClick={() => handleToggleRegistration(!allowRegistration)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
}`}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
allowRegistration ? 'translate-x-6' : 'translate-x-1'
}`}
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
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>
</div>
@@ -869,6 +899,17 @@ export default function AdminPage(): React.ReactElement {
/>
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
</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>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
<input
@@ -896,14 +937,12 @@ export default function AdminPage(): React.ReactElement {
</div>
<button
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 ${
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
}`}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
}`}
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
@@ -912,7 +951,7 @@ export default function AdminPage(): React.ReactElement {
onClick={async () => {
setSavingOidc(true)
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
await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved'))
@@ -930,49 +969,211 @@ export default function AdminPage(): React.ReactElement {
</button>
</div>
</div>
{/* SMTP / Notifications */}
{/* 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.smtp.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
<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-3">
{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' },
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://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}
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<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>
))}
<button
onClick={async () => {
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).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 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
)
})()}
{/* 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>
@@ -980,9 +1181,13 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'github' && <GitHubPanel />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
</div>
@@ -1122,78 +1327,37 @@ export default function AdminPage(): React.ReactElement {
)}
</Modal>
{/* Update confirmation popup — matches backup restore style */}
{/* Update instructions popup */}
{showUpdateModal && (
<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 }}
onClick={() => { if (!updating) setShowUpdateModal(false) }}
onClick={() => setShowUpdateModal(false)}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
{updateResult === 'success' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', 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 }}>
<CheckCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
</div>
</div>
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
<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>
<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 }}>
<ArrowUpCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.howTo')}</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' }}>
{updateInfo?.is_docker ? (
<>
<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={{ padding: '20px 24px' }}>
<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' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
<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"
>
{`docker pull mauriceboe/nomad:latest
docker stop nomad && docker rm nomad
docker run -d --name nomad \\
@@ -1202,90 +1366,93 @@ docker run -d --name nomad \\
-v /opt/nomad/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/nomad:latest`}
</div>
</div>
<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"
>
<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>
</>
) : (
<>
<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 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"
>
<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>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowUpdateModal(false)}
disabled={updating}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
{!updateInfo?.is_docker && (
<button
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>
)}
{updateInfo?.release_url && (
<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">
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
{t('admin.update.button')}
</a>
</span>
</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>
)}
{/* 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>
)
}
+179 -16
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react'
import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
@@ -127,6 +127,7 @@ export default function AtlasPage(): React.ReactElement {
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = 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 => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
@@ -139,7 +140,7 @@ export default function AtlasPage(): React.ReactElement {
// Border glow that follows cursor
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.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 = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
@@ -170,6 +171,26 @@ export default function AtlasPage(): React.ReactElement {
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const 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
useEffect(() => {
Promise.all([
@@ -231,8 +252,7 @@ export default function AtlasPage(): React.ReactElement {
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
loading: true,
crossOrigin: true
}).addTo(map)
// Preload adjacent zoom level tiles
@@ -292,6 +312,7 @@ export default function AtlasPage(): React.ReactElement {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
country_layer_by_a2_ref.current[c.code] = layer
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 tooltipHtml = `
@@ -337,6 +358,7 @@ export default function AtlasPage(): React.ReactElement {
const isoA2 = feature.properties?.ISO_A2
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
if (countryCode && countryCode !== '-99') {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
@@ -366,6 +388,23 @@ export default function AtlasPage(): React.ReactElement {
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
}
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> => {
if (!confirmAction) return
const { type, code } = confirmAction
@@ -494,6 +533,129 @@ export default function AtlasPage(): React.ReactElement {
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
{/* Map */}
<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 */}
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
@@ -551,7 +713,7 @@ export default function AtlasPage(): React.ReactElement {
bucketForm={bucketForm} setBucketForm={setBucketForm}
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
t={t} dark={dark}
@@ -629,24 +791,24 @@ export default function AtlasPage(): React.ReactElement {
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<CustomSelect
value={bucketMonth}
value={String(bucketMonth)}
onChange={v => setBucketMonth(Number(v))}
placeholder={t('atlas.month')}
options={[
{ value: 0, label: '—' },
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
{ value: '0', label: '—' },
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
]}
size="sm"
/>
</div>
<div style={{ flex: 1 }}>
<CustomSelect
value={bucketYear}
value={String(bucketYear)}
onChange={v => setBucketYear(Number(v))}
placeholder={t('atlas.year')}
options={[
{ value: 0, label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
{ value: '0', label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
]}
size="sm"
/>
@@ -717,6 +879,7 @@ interface SidebarContentProps {
onSearchBucket: () => Promise<void>
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
setBucketSearchResults: (v: string[]) => void
bucketPoiMonth: number
setBucketPoiMonth: (v: number) => void
bucketPoiYear: number
@@ -728,7 +891,7 @@ interface SidebarContentProps {
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
@@ -854,12 +1017,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{/* Month / Year with CustomSelect */}
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
<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={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
<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' }}>
+111 -57
View File
@@ -10,12 +10,14 @@ import DemoBanner from '../components/Layout/DemoBanner'
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
LayoutGrid, List,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
LayoutGrid, List, Copy,
} from 'lucide-react'
import { useCanDo } from '../store/permissionsStore'
interface DashboardTrip {
id: number
@@ -29,6 +31,7 @@ interface DashboardTrip {
owner_username?: string
day_count?: number
place_count?: number
shared_count?: number
[key: string]: string | number | boolean | null | undefined
}
@@ -56,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
}
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
@@ -138,16 +141,17 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
interface TripCardProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onDelete: (trip: DashboardTrip) => void
onArchive: (id: number) => void
onEdit?: (trip: DashboardTrip) => void
onCopy?: (trip: DashboardTrip) => void
onDelete?: (trip: DashboardTrip) => void
onArchive?: (id: number) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
dark?: boolean
}
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -186,12 +190,15 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
</div>
{/* Top-right actions */}
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
<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>
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
</div>
)}
{/* Bottom content */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
@@ -220,6 +227,9 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
</div>
</div>
</div>
</div>
@@ -228,7 +238,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -303,21 +313,25 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
</div>
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
<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')} />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
</div>
)}
</div>
</div>
)
}
// ── List View Item ──────────────────────────────────────────────────────────
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -400,14 +414,20 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<MapPin size={11} /> {trip.place_count || 0}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<Users size={11} /> {trip.shared_count+1 || 0}
</div>
</div>
{/* Actions */}
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label="" />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
</div>
)}
</div>
)
}
@@ -415,23 +435,24 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
// ── Archived Trip Row ────────────────────────────────────────────────────────
interface ArchivedRowProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onUnarchive: (id: number) => void
onDelete: (trip: DashboardTrip) => void
onEdit?: (trip: DashboardTrip) => void
onCopy?: (trip: DashboardTrip) => void
onUnarchive?: (id: number) => void
onDelete?: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
}
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
function ArchivedRow({ trip, onEdit, onCopy, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
<div onClick={() => onClick(trip)} style={{
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',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-faint)'}>
{/* Mini cover */}
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
@@ -440,8 +461,8 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<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>
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</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: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
</div>
{trip.start_date && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
@@ -449,18 +470,25 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
</div>
)}
</div>
{(onEdit || onCopy || onUnarchive || onDelete) && (
<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' }}
{onCopy && <button onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')} 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)' }}
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)' }}>
<Copy size={12} />
</button>}
{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)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
<ArchiveRestore size={12} /> {t('dashboard.restore')}
</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' }}
</button>}
{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' }}
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} />
</button>
</button>}
</div>
)}
</div>
)
}
@@ -527,6 +555,7 @@ export default function DashboardPage(): React.ReactElement {
const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => {
setViewMode(prev => {
@@ -541,6 +570,7 @@ export default function DashboardPage(): React.ReactElement {
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const can = useCanDo()
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const showCurrency = settings.dashboard_currency !== 'off'
@@ -595,16 +625,18 @@ export default function DashboardPage(): React.ReactElement {
}
}
const handleDelete = async (trip) => {
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
const handleDelete = (trip) => setDeleteTrip(trip)
const confirmDelete = async () => {
if (!deleteTrip) return
try {
await tripsApi.delete(trip.id)
setTrips(prev => prev.filter(t => t.id !== trip.id))
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
await tripsApi.delete(deleteTrip.id)
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
toast.success(t('dashboard.toast.deleted'))
} catch {
toast.error(t('dashboard.toast.deleteError'))
}
setDeleteTrip(null)
}
const handleArchive = async (id) => {
@@ -635,6 +667,16 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = async (trip: DashboardTrip) => {
try {
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -666,7 +708,7 @@ export default function DashboardPage(): React.ReactElement {
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
style={{
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,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
@@ -681,7 +723,7 @@ export default function DashboardPage(): React.ReactElement {
onClick={() => setShowWidgetSettings(s => s ? false : true)}
style={{
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,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
@@ -691,7 +733,7 @@ export default function DashboardPage(): React.ReactElement {
>
<Settings size={15} />
</button>
<button
{can('trip_create') && <button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
@@ -703,7 +745,7 @@ export default function DashboardPage(): React.ReactElement {
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={15} /> {t('dashboard.newTrip')}
</button>
</button>}
</div>
</div>
@@ -768,12 +810,12 @@ export default function DashboardPage(): React.ReactElement {
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
{t('dashboard.emptyText')}
</p>
<button
{can('trip_create') && <button
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' }}
>
<Plus size={16} /> {t('dashboard.emptyButton')}
</button>
</button>}
</div>
)}
@@ -782,9 +824,10 @@ export default function DashboardPage(): React.ReactElement {
<SpotlightCard
trip={spotlight}
t={t} locale={locale} dark={dark}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
)}
@@ -798,9 +841,10 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
@@ -812,9 +856,10 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
@@ -842,9 +887,10 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onUnarchive={handleUnarchive}
onDelete={handleDelete}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
@@ -893,6 +939,14 @@ export default function DashboardPage(): React.ReactElement {
onCoverUpdate={handleCoverUpdate}
/>
<ConfirmDialog
isOpen={!!deleteTrip}
onClose={() => setDeleteTrip(null)}
onConfirm={confirmDelete}
title={t('common.delete')}
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1 }
+150
View File
@@ -0,0 +1,150 @@
import React, { useEffect, useRef, useState } from 'react'
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
export default function InAppNotificationsPage(): React.ReactElement {
const { t } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchNotifications(true)
}, [])
// Reload when filter changes
useEffect(() => {
// We need to fetch with the unreadOnly filter — re-fetch from scratch
// The store fetchNotifications doesn't take a filter param directly,
// so we use the API directly for filtered view via a side channel.
// For now, reset and fetch — store always loads all, filter is client-side.
fetchNotifications(true)
}, [unreadOnly])
// Infinite scroll
useEffect(() => {
if (!loaderRef.current) return
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
fetchNotifications(false)
}
}, { threshold: 0.1 })
observer.observe(loaderRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium"
style={{ background: '#6366f1', color: '#fff' }}>
{unreadCount}
</span>
)}
</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
{total} {total === 1 ? 'notification' : 'notifications'}
</p>
</div>
{/* Bulk actions */}
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<CheckCheck className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-red-500 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
</button>
)}
</div>
</div>
{/* Filter toggle */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setUnreadOnly(false)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: !unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: !unreadOnly ? '#fff' : 'var(--text-secondary)',
}}
>
{t('notifications.all')}
</button>
<button
onClick={() => setUnreadOnly(true)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: unreadOnly ? '#fff' : 'var(--text-secondary)',
}}
>
{t('notifications.unreadOnly')}
</button>
</div>
{/* Notification list */}
<div
className="rounded-xl border overflow-hidden"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
<Bell className="w-12 h-12" style={{ color: 'var(--text-faint)' }} />
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+93 -30
View File
@@ -4,11 +4,13 @@ import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
interface AppConfig {
has_users: boolean
allow_registration: boolean
setup_complete: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
@@ -28,7 +30,7 @@ export default function LoginPage(): React.ReactElement {
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
@@ -48,17 +50,16 @@ export default function LoginPage(): React.ReactElement {
setError('Invalid or expired invite link')
})
window.history.replaceState({}, '', window.location.pathname)
return
}
if (oidcCode) {
setIsLoading(true)
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(data => {
.then(async data => {
if (data.token) {
localStorage.setItem('auth_token', data.token)
await loadUser()
navigate('/dashboard', { replace: true })
} else {
setError(data.error || 'OIDC login failed')
@@ -85,7 +86,7 @@ export default function LoginPage(): React.ReactElement {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
window.location.href = '/api/auth/oidc/login'
}
}
@@ -110,26 +111,46 @@ export default function LoginPage(): React.ReactElement {
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = 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> => {
e.preventDefault()
setError('')
setIsLoading(true)
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 (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
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)
setTimeout(() => navigate('/dashboard'), 2600)
return
}
if (mode === 'register') {
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)
} else {
const result = await login(email, password)
@@ -140,16 +161,22 @@ export default function LoginPage(): React.ReactElement {
setIsLoading(false)
return
}
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.error'))
setError(getApiErrorMessage(err, t('login.error')))
setIsLoading(false)
}
}
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
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
) : (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
{passwordChangeStep
? t('login.setNewPassword')
: mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
{passwordChangeStep
? t('login.setNewPasswordHint')
: mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -537,18 +568,50 @@ export default function LoginPage(): React.ReactElement {
</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>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}>
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="000000"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
placeholder="000000 or XXXX-XXXX"
required
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Username (register only) */}
{mode === 'register' && (
{mode === 'register' && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
<div style={{ position: 'relative' }}>
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Email */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Password */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{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'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
? <><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} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
}
</button>
</form>
{/* 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' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
return
}
if (password.length < 6) {
if (password.length < 8) {
setError(t('register.passwordTooShort'))
return
}
+501 -74
View File
@@ -1,23 +1,35 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
import apiClient from '../api/client'
import { useAddonStore } from '../store/addonStore'
import type { LucideIcon } from 'lucide-react'
import type { UserWithOidc } from '../types'
import { getApiErrorMessage } from '../types'
import { MapView } from '../components/Map/MapView'
import type { Place } from '../types'
interface MapPreset {
name: 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[] = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
@@ -46,99 +58,112 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
)
}
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
const [addons, setAddons] = useState<Record<string, boolean>>({})
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
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(() => {
apiClient.get('/addons').then(r => {
const map: Record<string, boolean> = {}
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
setAddons(map)
authApi.getAppConfig?.().then((cfg: any) => {
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
}).catch(() => {})
}, [])
const toggle = async (key: string) => {
if (!prefs) return
const newVal = prefs[key] ? 0 : 1
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
if (notifChannel === 'none') {
return (
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationsDisabled')}
</p>
)
}
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
const options = [
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
]
const channelLabel = notifChannel === 'email'
? (t('admin.notifications.email') || 'Email (SMTP)')
: (t('admin.notifications.webhook') || 'Webhook')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{options.map(opt => (
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
<button onClick={() => toggle(opt.key)}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
))}
<div 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 {
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 avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [saving, setSaving] = useState<Record<string, boolean>>({})
// Immich
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
// Addon gating (derived from store)
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const [appVersion, setAppVersion] = useState<string | null>(null)
useEffect(() => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
}, [])
const [immichUrl, setImmichUrl] = useState('')
const [immichApiKey, setImmichApiKey] = useState('')
const [immichConnected, setImmichConnected] = useState(false)
const [immichTesting, setImmichTesting] = useState(false)
useEffect(() => {
apiClient.get('/addons').then(r => {
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
setMemoriesEnabled(!!mem)
if (mem) {
apiClient.get('/integrations/immich/settings').then(r2 => {
setImmichUrl(r2.data.immich_url || '')
setImmichConnected(r2.data.connected)
}).catch(() => {})
}
}).catch(() => {})
const handleMapClick = useCallback((mapInfo) => {
setDefaultLat(mapInfo.latlng.lat)
setDefaultLng(mapInfo.latlng.lng)
}, [])
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 {
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
if (saveRes.data.warning) toast.warning(saveRes.data.warning)
toast.success(t('memories.saved'))
// Test connection
const res = await apiClient.get('/integrations/immich/status')
setImmichConnected(res.data.connected)
setImmichTestPassed(false)
} catch {
toast.error(t('memories.connectionError'))
} finally {
@@ -149,13 +174,18 @@ export default function SettingsPage(): React.ReactElement {
const handleTestImmich = async () => {
setImmichTesting(true)
try {
const res = await apiClient.get('/integrations/immich/status')
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
if (res.data.connected) {
toast.success(`${t('memories.connectionSuccess')}${res.data.user?.name || ''}`)
setImmichConnected(true)
if (res.data.canonicalUrl) {
setImmichUrl(res.data.canonicalUrl)
toast.success(`${t('memories.connectionSuccess')}${res.data.user?.name || ''} (URL updated to ${res.data.canonicalUrl})`)
} else {
toast.success(`${t('memories.connectionSuccess')}${res.data.user?.name || ''}`)
}
setImmichTestPassed(true)
} else {
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
setImmichConnected(false)
setImmichTestPassed(false)
}
} catch {
toast.error(t('memories.connectionError'))
@@ -164,12 +194,98 @@ export default function SettingsPage(): React.ReactElement {
}
}
// MCP tokens
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [mcpNewName, setMcpNewName] = useState('')
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
useEffect(() => {
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
}, [])
const handleCreateMcpToken = async () => {
if (!mcpNewName.trim()) return
setMcpCreating(true)
try {
const d = await authApi.mcpTokens.create(mcpNewName.trim())
setMcpCreatedToken(d.token.raw_token)
setMcpNewName('')
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
} catch {
toast.error(t('settings.mcp.toast.createError'))
} finally {
setMcpCreating(false)
}
}
const handleDeleteMcpToken = async (id: number) => {
try {
await authApi.mcpTokens.delete(id)
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
setMcpDeleteId(null)
toast.success(t('settings.mcp.toast.deleted'))
} catch {
toast.error(t('settings.mcp.toast.deleteError'))
}
}
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
})
}
const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`
// Map settings
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
const mapPlaces = useMemo(() => {
// Add center location to map places
let places: Place[] = []
places.push({
id: 1,
trip_id: 1,
name: "Default map center",
description: "",
lat: defaultLat as number,
lng: defaultLng as number,
address: "",
category_id: 0,
icon: null,
price: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: Date()
});
return places
}, [defaultLat, defaultLng])
// Display
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
@@ -193,6 +309,71 @@ export default function SettingsPage(): React.ReactElement {
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
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(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -288,7 +469,7 @@ export default function SettingsPage(): React.ReactElement {
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value=""
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({
@@ -331,6 +512,29 @@ export default function SettingsPage(): React.ReactElement {
</div>
</div>
<div>
<div style={{ position: 'relative', inset: 0, height:"200px", width: "100%" }}>
<MapView
places={mapPlaces}
dayPlaces={[]}
route={null}
routeSegments={null}
selectedPlaceId={null}
onMarkerClick={null}
onMapClick={handleMapClick}
onMapContextMenu={null}
center = {[settings.default_lat, settings.default_lng]}
zoom={defaultZoom}
tileUrl={mapTileUrl}
fitKey={null}
dayOrderMap={[]}
leftWidth={0}
rightWidth={0}
hasInspector={false}
/>
</div>
</div>
<button
onClick={saveMapSettings}
disabled={saving.map}
@@ -539,19 +743,20 @@ export default function SettingsPage(): React.ReactElement {
<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)}
<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)}
<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}
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">
<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}
@@ -572,6 +777,162 @@ export default function SettingsPage(): React.ReactElement {
</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 */}
<Section title={t('settings.account')} icon={User}>
<div>
@@ -629,6 +990,7 @@ export default function SettingsPage(): React.ReactElement {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
@@ -652,6 +1014,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>
</div>
<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>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
@@ -709,12 +1084,21 @@ export default function SettingsPage(): React.ReactElement {
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaEnable({ code: mfaSetupCode })
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
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) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -766,7 +1150,9 @@ export default function SettingsPage(): React.ReactElement {
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
await loadUser()
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -779,6 +1165,29 @@ export default function SettingsPage(): React.ReactElement {
</button>
</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>
@@ -891,6 +1300,24 @@ export default function SettingsPage(): React.ReactElement {
</div>
</Section>
{appVersion && (
<Section title={t('settings.about')} icon={Info}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
title="Discord">
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
</div>
</Section>
)}
{/* Delete Account Confirmation */}
{showDeleteConfirm === 'blocked' && (
<div style={{
+10 -6
View File
@@ -4,6 +4,7 @@ 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'
@@ -43,7 +44,6 @@ export default function SharedTripPage() {
const [error, setError] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const { updateSetting } = useSettingsStore()
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
@@ -106,7 +106,7 @@ export default function SharedTripPage() {
{(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(' — ')}
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).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>}
@@ -127,7 +127,11 @@ export default function SharedTripPage() {
{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={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
<button key={lang.value} onClick={() => {
// Set language locally without API call (shared page has no auth)
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
setShowLangPicker(false)
}}
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
@@ -164,7 +168,7 @@ export default function SharedTripPage() {
{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" />
<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)}>
@@ -195,7 +199,7 @@ export default function SharedTripPage() {
<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>}
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</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 }}>
@@ -270,7 +274,7 @@ export default function SharedTripPage() {
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' }) : ''
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
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 }}>
+295 -69
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 { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapView } from '../components/Map/MapView'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector'
@@ -20,14 +22,15 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react'
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 { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
export default function TripPlannerPage(): React.ReactElement | null {
@@ -36,8 +39,28 @@ export default function TripPlannerPage(): React.ReactElement | null {
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
const trip = useTripStore(s => s.trip)
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 { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
const handleUndo = useCallback(async () => {
const label = lastActionLabel
await undo()
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
@@ -47,7 +70,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const loadAccommodations = useCallback(() => {
if (tripId) {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripStore.loadReservations(tripId)
tripActions.loadReservations(tripId)
}
}, [tripId])
@@ -63,13 +86,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan') },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') },
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(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') }] : []),
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
]
const [activeTab, setActiveTab] = useState<string>(() => {
@@ -80,8 +103,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
}
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
@@ -98,11 +121,33 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | 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)
useEffect(() => {
if (tripId) {
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripStore.loadFiles(tripId)
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
tripsApi.getMembers(tripId).then(d => {
// Combine owner + members into one list
@@ -113,30 +158,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId])
useEffect(() => {
if (tripId) tripStore.loadReservations(tripId)
if (tripId) tripActions.loadReservations(tripId)
}, [tripId])
useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
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 => {
if (!p.lat || !p.lng) return false
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
if (hiddenPlaceIds.has(p.id)) return false
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 changed = dayId !== selectedDayId
tripStore.setSelectedDay(dayId)
tripActions.setSelectedDay(dayId)
if (changed && !skipFit) setFitKey(k => k + 1)
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [tripStore, updateRouteForDay, selectedDayId])
}, [updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId, assignmentId) => {
if (assignmentId) {
@@ -158,6 +226,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [])
const handleMapContextMenu = useCallback(async (e) => {
if (!can('place_edit', trip)) return
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
@@ -179,11 +248,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
if (editingPlace) {
// Always strip time fields from place update — time is per-assignment only
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 (editingAssignmentId) {
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
if (pendingFiles?.length > 0) {
@@ -191,23 +260,29 @@ export default function TripPlannerPage(): React.ReactElement | null {
const fd = new FormData()
fd.append('file', file)
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'))
} else {
const place = await tripStore.addPlace(tripId, data)
const place = await tripActions.addPlace(tripId, data)
if (pendingFiles?.length > 0 && place?.id) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
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'))
if (place?.id) {
const capturedId = place.id
pushUndo(t('undo.addPlace'), async () => {
await tripActions.deletePlace(tripId, capturedId)
})
}
}
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
@@ -215,35 +290,85 @@ export default function TripPlannerPage(): React.ReactElement | null {
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
try {
await tripStore.deletePlace(tripId, deletePlaceId)
await tripActions.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
description: capturedPlace.description,
lat: capturedPlace.lat,
lng: capturedPlace.lng,
address: capturedPlace.address,
category_id: capturedPlace.category_id,
icon: capturedPlace.icon,
price: capturedPlace.price,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
try {
await tripStore.removeAssignment(tripId, dayId, assignmentId)
await tripActions.removeAssignment(tripId, dayId, assignmentId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
})
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, tripStore, toast, updateRouteForDay])
}, [tripId, toast, updateRouteForDay, pushUndo])
const handleReorder = useCallback((dayId, orderedIds) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
try {
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
tripActions.reorderAssignments(tripId, dayId, orderedIds)
.then(() => {
const capturedDayId = dayId
const capturedPrevIds = prevIds
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
})
.catch(() => {})
// 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 waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
@@ -251,17 +376,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
setRouteInfo(null)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, tripStore, toast])
}, [tripId, toast, pushUndo])
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') }
}, [tripId, tripStore, toast])
}, [tripId, toast])
const handleSaveReservation = async (data) => {
try {
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'))
setShowReservationModal(false)
if (data.type === 'hotel') {
@@ -269,7 +394,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}
return r
} 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'))
setShowReservationModal(false)
// Refresh accommodations if hotel was created
@@ -283,7 +408,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleDeleteReservation = async (id) => {
try {
await tripStore.deleteReservation(tripId, id)
await tripActions.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
@@ -320,12 +445,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<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' }} />
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
<div style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'var(--bg-primary)', ...fontStyle,
}}>
<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>
)
@@ -350,10 +516,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
}}>
{TRIP_TABS.map(tab => {
const isActive = activeTab === tab.id
const TabIcon = tab.icon
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
title={tab.label}
style={{
flexShrink: 0,
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
@@ -361,13 +529,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
fontFamily: 'inherit', transition: 'all 0.15s',
display: 'flex', alignItems: 'center', gap: 5,
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
>{tab.shortLabel
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
: tab.label
}</button>
>
{TabIcon && <><TabIcon size={20} className="sm:hidden" /><TabIcon size={15} className="hidden sm:block" /></>}
<span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>
</button>
)
})}
</div>
@@ -440,13 +609,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
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) } }}
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) }}
onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
onNavigateToFiles={() => handleTabChange('dateien')}
onExpandedDaysChange={setExpandedDayIds}
pushUndo={pushUndo}
canUndo={canUndo}
lastActionLabel={lastActionLabel}
onUndo={handleUndo}
/>
{!leftCollapsed && (
<div
@@ -507,6 +681,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onCategoryFilterChange={setMapCategoryFilter}
pushUndo={pushUndo}
/>
</div>
</div>
@@ -542,15 +717,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
reservations={reservations}
lat={geoPlace?.lat}
lng={geoPlace?.lng}
onClose={() => setShowDayDetail(null)}
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
onAccommodationChange={loadAccommodations}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
/>
)
})()}
{selectedPlace && (
{selectedPlace && !isMobile && (
<PlaceInspector
place={selectedPlace}
categories={categories}
@@ -561,7 +736,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => {
// When editing from assignment context, use assignment-level times
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
@@ -576,7 +750,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
files={files}
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
tripMembers={tripMembers}
onSetParticipants={async (assignmentId, dayId, userIds) => {
try {
@@ -591,12 +765,64 @@ export default function TripPlannerPage(): React.ReactElement | null {
}))
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
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(
<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()}>
@@ -608,8 +834,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
? <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} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: <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} pushUndo={pushUndo} />
}
</div>
</div>
@@ -651,9 +877,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
<FileManager
files={files || []}
onUpload={(fd) => tripStore.addFile(tripId, fd)}
onDelete={(id) => tripStore.deleteFile(tripId, id)}
onUpdate={(id, data) => tripStore.loadFiles(tripId)}
onUpload={(fd) => tripActions.addFile(tripId, fd)}
onDelete={(id) => tripActions.deleteFile(tripId, id)}
onUpdate={(id, data) => tripActions.loadFiles(tripId)}
places={places}
days={days}
assignments={assignments}
@@ -677,10 +903,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
</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)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<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 tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<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
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+23 -11
View File
@@ -41,11 +41,16 @@ export default function VacayPage(): React.ReactElement {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
}, [selectedYear])
const handleAddYear = () => {
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
}
const handleAddPrevYear = () => {
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
addYear(prevYear)
}
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement {
<>
{/* Year Selector */}
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
<div className="flex items-center justify-between mb-2">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}>
<Plus size={14} />
</button>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-1">
{years.map(y => (
+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)
},
}))
+55 -35
View File
@@ -17,41 +17,54 @@ interface AvatarResponse {
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
demoMode: boolean
devMode: boolean
hasMapsKey: boolean
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
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
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>
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
updateProfile: (profileData: Partial<User>) => Promise<void>
uploadAvatar: (file: File) => Promise<AvatarResponse>
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setDevMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token') || null,
isAuthenticated: !!localStorage.getItem('auth_token'),
isLoading: false,
isAuthenticated: false,
isLoading: true,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
login: async (email: string, password: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
@@ -59,15 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token }
}
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
connect(data.token)
connect()
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
@@ -77,18 +88,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
completeMfaLogin: async (mfaToken: string, code: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
connect(data.token)
connect()
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Verification failed')
@@ -98,18 +108,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
register: async (username: string, email: string, password: string, invite_token?: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password, invite_token })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
connect(data.token)
connect()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
@@ -120,38 +129,47 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => {
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({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
loadUser: async () => {
const token = get().token
if (!token) {
set({ isLoading: false })
return
}
set({ isLoading: true })
loadUser: async (opts?: { silent?: boolean }) => {
const seq = authSequence
const silent = !!opts?.silent
if (!silent) set({ isLoading: true })
try {
const data = await authApi.me()
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
set({
user: data.user,
isAuthenticated: true,
isLoading: false,
})
connect(token)
connect()
} catch (err: unknown) {
localStorage.removeItem('auth_token')
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
})
} else {
set({ isLoading: false })
}
}
},
@@ -203,23 +221,25 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ demoMode: val })
},
setDevMode: (val: boolean) => set({ devMode: 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 () => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.demoLogin()
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
demoMode: true,
error: null,
})
connect(data.token)
connect()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Demo login failed')
+192
View File
@@ -0,0 +1,192 @@
import { create } from 'zustand'
import { inAppNotificationsApi } from '../api/client'
export interface InAppNotification {
id: number
type: 'simple' | 'boolean' | 'navigate'
scope: 'trip' | 'user' | 'admin'
target: number
sender_id: number | null
sender_username: string | null
sender_avatar: string | null
recipient_id: number
title_key: string
title_params: Record<string, string>
text_key: string
text_params: Record<string, string>
positive_text_key: string | null
negative_text_key: string | null
response: 'positive' | 'negative' | null
navigate_text_key: string | null
navigate_target: string | null
is_read: boolean
created_at: string
}
interface RawNotification extends Omit<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
title_params: string | Record<string, string>
text_params: string | Record<string, string>
is_read: number | boolean
}
function normalizeNotification(raw: RawNotification): InAppNotification {
return {
...raw,
title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params,
text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params,
is_read: Boolean(raw.is_read),
}
}
interface NotificationState {
notifications: InAppNotification[]
unreadCount: number
total: number
isLoading: boolean
hasMore: boolean
fetchNotifications: (reset?: boolean) => Promise<void>
fetchUnreadCount: () => Promise<void>
markRead: (id: number) => Promise<void>
markUnread: (id: number) => Promise<void>
markAllRead: () => Promise<void>
deleteNotification: (id: number) => Promise<void>
deleteAll: () => Promise<void>
respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>
handleNewNotification: (notification: RawNotification) => void
handleUpdatedNotification: (notification: RawNotification) => void
}
const PAGE_SIZE = 20
export const useInAppNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
total: 0,
isLoading: false,
hasMore: false,
fetchNotifications: async (reset = false) => {
const { notifications, isLoading } = get()
if (isLoading) return
set({ isLoading: true })
try {
const offset = reset ? 0 : notifications.length
const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
const normalized = (data.notifications as RawNotification[]).map(normalizeNotification)
set({
notifications: reset ? normalized : [...notifications, ...normalized],
total: data.total,
unreadCount: data.unread_count,
hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total,
isLoading: false,
})
} catch {
set({ isLoading: false })
}
},
fetchUnreadCount: async () => {
try {
const data = await inAppNotificationsApi.unreadCount()
set({ unreadCount: data.count })
} catch {
// best-effort
}
},
markRead: async (id: number) => {
try {
await inAppNotificationsApi.markRead(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n),
unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)),
}))
} catch {
// best-effort
}
},
markUnread: async (id: number) => {
try {
await inAppNotificationsApi.markUnread(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n),
unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0),
}))
} catch {
// best-effort
}
},
markAllRead: async () => {
try {
await inAppNotificationsApi.markAllRead()
set(state => ({
notifications: state.notifications.map(n => ({ ...n, is_read: true })),
unreadCount: 0,
}))
} catch {
// best-effort
}
},
deleteNotification: async (id: number) => {
const notification = get().notifications.find(n => n.id === id)
try {
await inAppNotificationsApi.delete(id)
set(state => ({
notifications: state.notifications.filter(n => n.id !== id),
total: Math.max(0, state.total - 1),
unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
}))
} catch {
// best-effort
}
},
deleteAll: async () => {
try {
await inAppNotificationsApi.deleteAll()
set({ notifications: [], total: 0, unreadCount: 0, hasMore: false })
} catch {
// best-effort
}
},
respondToBoolean: async (id: number, response: 'positive' | 'negative') => {
try {
const data = await inAppNotificationsApi.respond(id, response)
if (data.notification) {
const normalized = normalizeNotification(data.notification as RawNotification)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? normalized : n),
unreadCount: !state.notifications.find(n => n.id === id)?.is_read
? Math.max(0, state.unreadCount - 1)
: state.unreadCount,
}))
}
} catch {
// best-effort
}
},
handleNewNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: [notification, ...state.notifications],
total: state.total + 1,
unreadCount: state.unreadCount + 1,
}))
},
handleUpdatedNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: state.notifications.map(n => n.id === notification.id ? notification : n),
}))
},
}))
+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) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
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)
])
),
}))
set(state => {
const updatedAssignments = { ...state.assignments }
let changed = false
for (const [dayId, items] of Object.entries(state.assignments)) {
if (items.some((a: Assignment) => a.place?.id === placeId)) {
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
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating place'))
@@ -55,15 +62,20 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter((a: Assignment) => a.place?.id !== placeId)
])
),
}))
set(state => {
const updatedAssignments = { ...state.assignments }
let changed = false
for (const [dayId, items] of Object.entries(state.assignments)) {
if (items.some((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) {
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
}
+9 -1
View File
@@ -222,7 +222,14 @@ export const useVacayStore = create<VacayState>((set, get) => ({
removeYear: async (year: number) => {
const data = await api.removeYear(year)
set({ years: data.years })
const updates: Partial<VacayState> = { years: data.years }
if (get().selectedYear === year) {
updates.selectedYear = data.years.length > 0
? data.years[data.years.length - 1]
: new Date().getFullYear()
}
set(updates)
await get().loadStats()
},
loadEntries: async (year?: number) => {
@@ -240,6 +247,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
toggleCompanyHoliday: async (date: string) => {
await api.toggleCompanyHoliday(date)
await get().loadEntries()
await get().loadStats()
},
loadStats: async (year?: number) => {

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