Compare commits

...

68 Commits

Author SHA1 Message Date
github-actions[bot] 83be5fc92a chore: bump version to 3.0.3 [skip ci] 2026-04-22 20:16:47 +00:00
Julien G. 7798d2a3fd fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834
2026-04-22 22:16:33 +02:00
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
Julien G. 3ada075b1a Merge pull request #831 from mauriceboe/fix/transport-modal-price-budget-fields
fix: restore Price and Budget Category fields in Edit Transport dialog
2026-04-22 18:55:53 +02:00
jubnl afce302b59 fix: restore price and budget category fields in TransportModal 2026-04-22 18:50:42 +02:00
Maurice 8e8433fa9d docs: align Home.md + README addon list + Tags/Photo-Providers wording with dev state
- Home.md: addon list (9 real addons), MCP numbers (150+ tools, 30 resources, 27 scopes), admin-seeding text
- README.md: expand addon list from 5 to 9 (Lists/Budget/Documents/Naver/MCP in, Dashboard widgets out)
- Photo-Providers.md: 'Memories addon' -> photo provider toggles under Journey
- Admin-Addons.md: Journey works without photo providers; they are optional sub-toggles
- Tags-and-Categories.md: add Personal Tags section (user-scoped, MCP-only for now)
2026-04-22 18:22:22 +02:00
Maurice ff42fa0b8c docs: sync README with current dev state
- MCP: 80+ tools/27 resources -> 150+ tools/30 resources
- MCP: 24 -> 27 OAuth scopes
- i18n: 14 -> 15 languages
- admin seeding on first boot (not first-to-register)
- nginx: client_max_body_size 50m -> 500m, add proxy_read_timeout 86400 on /ws
2026-04-22 18:10:27 +02:00
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00
jubnl 45a5b4e588 fix: remove obsolete map share toggle and make public desktop entries openable
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.

Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
2026-04-22 16:33:04 +02:00
jubnl 82cce365f7 fix: validate image-only uploads and respect allowed_file_types setting for journey photos
Add fileFilter to the journey photo multer config (shared by entry photo
upload and gallery upload routes):
- Rejects any non-image MIME type (including SVG which carries XSS risk)
- Checks the extension against the admin-configured allowed_file_types setting
  (same getAllowedExtensions() used by the trip file upload route)
- Returns HTTP 400 with a descriptive message on rejection

Also fix the global error handler to return err.message for 4xx responses
instead of the generic 'Internal server error', so fileFilter rejections
produce a readable error on the client.
2026-04-22 16:16:35 +02:00
jubnl ed7e2badca fix: catch sharp errors in ensureLocalThumbnail and fall back to original
Sharp throws on unsupported formats (HEIC, corrupt files, etc.) and the
error was propagated outside the try/catch, crashing the server. Moved the
mkdir + sharp pipeline inside the catch block so any failure returns null
and streamPhoto falls through to serving the original file.
2026-04-22 16:11:38 +02:00
jubnl ba7b99fb7d fix: update backend tests and service bugs for gallery 1-to-N schema
updatePhoto: write sort_order to journey_entry_photos (junction) not journey_photos,
since JP_SELECT reads jep.sort_order — updating the gallery row had no visible effect.

deletePhoto: include id in return value so callers that check deleted.id still work.

Tests updated for new schema:
- journeyShareService: insertJourneyPhoto helper now inserts into journey_photos
  (keyed by journey_id) + journey_entry_photos junction instead of the old
  entry_id-keyed table
- SVC-081: deleteEntry cascades junction rows (journey_entry_photos), not gallery
  rows (journey_photos); assert junction is gone, gallery is preserved
- SVC-086: syncTripPhotos now populates the gallery directly — no [Trip Photos]
  wrapper entry; assert journey_photos gallery row instead
- INT-028: error message updated to 'journey_photo_id required'
2026-04-22 16:05:18 +02:00
jubnl 71aa8f8051 feat: journey gallery 1-to-N model with M:N entry-photo junction table
Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.

Key changes:
- Migration 121: renames old journey_photos to journey_photos_old, creates the
  new gallery table + junction table, backfills both from existing data, drops
  the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries
- journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via
  journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos,
  addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies
  deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts
  directly into the gallery instead of a wrapper entry
- journeyShareService: updates public photo and asset validation queries to join
  through the gallery table instead of entry_id; getPublicJourney now returns a
  dedicated gallery array alongside per-entry photos
- journey routes: adds gallery upload, provider-photo, and delete endpoints
  (POST/DELETE /:id/gallery/*); adds unlink-from-entry route
  (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to
  accept journey_photo_id with a backwards-compat photo_id alias
- types: adds GalleryPhoto interface
- client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto,
  deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId
- journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail,
  uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions
- JourneyDetailPage + tests: updated to work with the new gallery model
2026-04-22 15:58:31 +02:00
jubnl 7c9e945b8c fix: serve real thumbnails for local photos instead of full-resolution originals (#822)
Add thumbnailService that lazy-generates a WebP thumbnail (800px max, q80) on
first GET /api/photos/:id/thumbnail request using sharp. The generated file is
stored at uploads/journey/thumbs/<sha1>.webp and the path is persisted to
trek_photos.thumbnail_path so subsequent requests are served directly from disk.
Also populates width/height as a side-effect.

streamPhoto now branches on kind for local file_path rows — thumbnail requests
use the stored/generated thumb path; original requests (and fallback when thumb
generation fails) continue to serve the full file. Remote providers (Immich,
Synology) are unaffected.
2026-04-22 15:56:34 +02:00
jubnl f6b3931bc4 fix: mobile public share — remove map tab (#828), cap timeline width (#827), wire entry click (#826)
- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
  shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
  map aside is not dwarfed on wide monitors; applies to both desktop two-column
  layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
  MobileEntryView with readOnly + public photo URL builder so photos load via
  the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
  photo URLs are routable for both authenticated and public-share contexts
2026-04-22 15:56:20 +02:00
Maurice 9e3041305c docs: remove badge icons + Roadmap board->view 2026-04-22 00:00:46 +02:00
Maurice 78fc557143 docs: remove icons from badges 2026-04-22 00:00:27 +02:00
Maurice 8a2fec8de0 docs: shorten badge labels (Demo/Try, Discord/Join, Ko-fi/Support, BMAC/Support) 2026-04-21 23:58:49 +02:00
Maurice e109dc0b51 docs: subtitle onto its own line under the logo + Ko-fi/BMAC badges
- <br /> between the TREK logo and the subtitle picture so the
  subtitle sits below the logo instead of rendering next to it.
- New badge row with Ko-fi and Buy Me a Coffee in the same
  for-the-badge style as Live Demo / Docker / Discord / Roadmap.
2026-04-21 23:39:54 +02:00
Julien G. 88d980c657 Merge pull request #820 from mauriceboe/fix/802-819-journey-gallery-mobile-fixes
fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819)
2026-04-21 23:32:24 +02:00
jubnl 3f489880da fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819)
Fix #802: ProviderPicker modal now uses dvh-based max-height, items-end
on mobile (bottom-sheet), flex-shrink-0 on all fixed sections, min-h-0
on the scrollable grid, and env(safe-area-inset-bottom) padding so the
Add button is always reachable above the iOS home indicator.

Fix #819: Gallery view now deduplicates photos by photo_id (underlying
trek_photos.id) so a photo linked from Gallery into an activity no longer
appears twice. Gallery delete cascades to all copies. EntryEditor From
Gallery grid and photo count also deduplicated. Server photo_count uses
COUNT(DISTINCT photo_id). Preserves #729 guarantee (removing from an
activity does not delete the Gallery copy).
2026-04-21 23:26:02 +02:00
Julien G. 45fa6fd0d3 Merge pull request #809 from mauriceboe/fix/789-800-journey-mobile-fixes
fix(journey): resolve issues #789–801 — mobile layout, day colors, location formatting, date picker, public share UX
2026-04-21 22:56:54 +02:00
jubnl a8c27f9d4a test: update tests to match translated share link button and desktop two-column map layout
- 'Remove share link' → 'Delete link' (now uses share.deleteLink i18n key)
- FE-PAGE-PUBLICJOURNEY-009/012: map tab no longer exists in desktop two-column
  layout; map is always rendered in the sidebar — tests updated to verify the
  journey-map testid is present without requiring a tab click
2026-04-21 22:51:48 +02:00
jubnl 288d33ba42 fix(journey/mobile): eliminate carousel scroll stutter on mobile
- Defer activeIndex updates until scrolling settles (150ms debounce)
  instead of updating every RAF — mid-swipe card resize (240→320px)
  caused layout reflow on every frame, which is the main stutter source
- Switch scrollSnapType from 'proximity' to 'mandatory' for reliable
  browser-native snapping without needing a JS re-center pass
- Remove scroll-smooth CSS class (conflicts with mandatory snap)
- Remove the post-settle scrollIntoView call (mandatory snap handles it)
- Drop the now-unused activeIndexRef

Closes #818
2026-04-21 22:42:32 +02:00
jubnl e7fb78dc1e fix(journey/settings): translate 'Remove share link' button using share.deleteLink key 2026-04-21 22:42:31 +02:00
jubnl 4d3bf390a5 feat(journey/settings): warn on unsaved changes before closing modal
- Track dirty state (title/subtitle changed from original)
- Intercept X button, backdrop click, and Cancel with handleClose
- Show ConfirmDialog when dirty; proceed with onClose only on confirm
- Add common.discardChanges and common.discard keys to all 15 locales
2026-04-21 22:42:31 +02:00
jubnl 001b2365a1 fix(journey): correct map marker color offset and scroll-sync for unlocated entries
- sidebarMapItems now derives dayIdx from all timeline dates (not just
  located-entry dates), so markers stay color-aligned with day headers
  even when some days have no location
- scroll-sync no longer calls highlightMarker for unlocated entries,
  preventing the map from clearing or misfiring when the scroll winner
  has no corresponding marker
- same dayIdx fix applied to JourneyPublicPage desktop two-column view
2026-04-21 22:42:30 +02:00
jubnl 7d5dadc441 feat(journey/public): match desktop timeline view to in-app experience 2026-04-21 22:42:30 +02:00
jubnl c912ad4b01 fix(journey): expand DAY_COLORS to 30 unique colors to cover a full month 2026-04-21 22:40:48 +02:00
jubnl bd6cd55a13 fix(journey): resolve issues #789-801 — mobile layout, day colors, location formatting, date picker, public share UX 2026-04-21 22:40:47 +02:00
Maurice 757764d046 hotfix: offline banner as bottom pill instead of full-width top bar
The top bar still blocked the trip planner's top nav on mobile even
after #808's padding trick — nav layouts that position their own
sticky headers were ignoring the --offline-banner-h offset, and the
bar looked alarming for what is usually a 2s blip.

Redesign as a small floating pill anchored bottom-center, hovering
above the mobile bottom nav (calc(var(--bottom-nav-h) + 16px)). No
layout shift anywhere, nothing ever covers the nav, and the pill
looks like a passing status chip rather than an error banner.

Reverts the body padding-top / navbar top offset introduced in #808
since they're no longer needed with the pill positioning.
2026-04-21 22:30:50 +02:00
Maurice 94e64acc34 Merge pull request #808 from mauriceboe/fix/modal-mobile-footer-visibility
fix: mobile polish batch (#803–#807, #810–#815)
2026-04-21 22:23:40 +02:00
Maurice 70ba24bfe1 fix(test): cancel Navbar theme-transition timer on unmount
The dark-mode toggle kicked off a 360ms setTimeout that removed a
CSS class via 'document.documentElement'. In vitest the document
was torn down before the timer fired, triggering an unhandled
ReferenceError that flipped the whole run to a non-zero exit even
though every test passed.

Track the handle in a ref and clearTimeout on unmount (and before
scheduling a new one).
2026-04-21 22:18:54 +02:00
Maurice 32f431e879 fix: translate months in journey timeline (#815)
formatDate() in both JourneyDetailPage and JourneyPublicPage passed
undefined/'en' as the locale to toLocaleDateString, so weekday/month
names always followed the browser's language instead of the app's
selected UI language. Thread the selected locale through from
useTranslation() in both pages.

Public view still falls back to 'en' when no settings locale is
available (shared links can be opened by anyone).
2026-04-21 22:16:43 +02:00
Maurice 906d8821a4 fix: offline banner no longer covers the top of the app (#813)
OfflineBanner was fixed at top:0 but the rest of the page had no
idea it was visible, so on mobile (and the desktop nav on wider
screens) the banner sat on top of the header content.

When the banner is visible it now sets --offline-banner-h on <html>;
body reserves that space via padding-top, and the desktop fixed
Navbar shifts its top by the same amount. When back online the var
is removed and everything snaps back.
2026-04-21 22:10:11 +02:00
Maurice 82b16a4bf5 fix(i18n): use 'polls' consistently in Dutch trip collab (#814)
Mixed 'peilingen' (titles/tabs) with 'poll/polls' (everywhere else).
Normalised to 'polls' per reporter's preference — more common in
modern Dutch usage anyway.
2026-04-21 22:05:33 +02:00
Maurice 069269e69c fix: integrations settings squish on mobile (#812) + polish
PhotoProvidersSection:
- Replace raw <input type=checkbox> with TREK's ToggleSwitch so the
  'spiegeln zu Immich'-style options match the rest of the app.
- Wrap action row in flex-wrap so the connected/disconnected badge
  drops to its own line on mobile instead of clipping.
- Add a short 'Test' translation (memories.testShort) shown on mobile
  in place of 'Test connection' — 14 languages kept in sync.

ToggleSwitch:
- Explicit type='button' (never a form submitter), minWidth + flex-
  shrink:0 so the toggle doesn't get squished next to long labels,
  padding:0 so no inherited UA margin warps the inner circle.

MapSettingsTab:
- 'Mapbox' instead of 'Mapbox GL' on narrow screens — the provider
  card is too cramped on mobile for the full name.
- Drop the 'Experimental' badge on mobile entirely; it overlapped
  the title at that width. Still shown on >=sm.

DisplaySettingsTab:
- Time format buttons show just '24h' / '12h' on mobile; the '(14:30)'
  / '(2:30 PM)' hint stays on >=sm. Test updated to match the role
  query since the label is now split across nodes.
2026-04-21 22:03:20 +02:00
Maurice 534149ba22 fix(test): query form by tag since Save button is now in Modal footer
After moving Save/Cancel into the Modal's sticky footer prop, the
button no longer lives inside the <form> element, so walking up via
closest('form') returns null. Query the form directly via
document.querySelector('form') — same semantics, just doesn't assume
the button is a descendant of the form.
2026-04-21 21:52:46 +02:00
Maurice 2dd6e04b44 fix: treat new-category placeholder name '...' as a UI placeholder (#811)
When a user adds a new packing category, the first item is seeded
with name '...' because the server rejects empty names. That string
was rendered as a real value in the input, forcing users to delete
the dots before typing. Now we detect the sentinel, show it as a
faint placeholder in the display span, and start the edit input
empty (with '...' as the HTML placeholder).
2026-04-21 21:50:56 +02:00
Maurice 0e3d9f6ddc fix: reservation card header overlap on mobile (#810)
Status and category chips collided with the reservation title on
narrow viewports because the header was a single-line flex with
inline chips of natural width. flexWrap on the outer row plus the
inner chip group lets the title+actions drop to a second row when
content overflows, so the chips and the title never overlap.
2026-04-21 21:46:58 +02:00
Maurice 3b7442c2d5 fix: bottom-nav related mobile cutoffs (#805, #806, #807)
TransportModal + ReservationModal: move Save/Cancel into the Modal's
footer slot so they stay visible on long forms (same fix as
PlaceFormModal in this PR).

DayDetailPanel: the floating day info panel was anchored at a fixed
bottom: 96px which didn't account for safe-area-inset-bottom, causing
it to overlap the bottom nav on devices with a home indicator. Use
calc(var(--bottom-nav-h) + 20px) so it always floats above the tab
bar with a safe gap.
2026-04-21 21:42:48 +02:00
Maurice 78b45d7c19 docs: replace README subtitle text with image (light/dark)
Swaps the 'Your trips. Your plan. Your server.' H3 for a rendered
subtitle image using <picture> + prefers-color-scheme, matching the
logo pattern.
2026-04-21 21:39:39 +02:00
Maurice 9e5100c71c fix: keep modal save button visible on mobile (#803, #804)
Two fixes in Modal.tsx:
- Replace 100vh with 100dvh so iOS Safari PWA respects the actual
  visible viewport. Explicitly subtract --bottom-nav-h on mobile so
  the modal never extends behind the tab bar.
- overflow-hidden on the container so the footer's bottom corners
  inherit rounded-2xl.
- flex-shrink-0 on header and footer + min-h-0 on the body so the
  body shrinks and scrolls while the footer stays put.

One fix in PlaceFormModal.tsx:
- Save/cancel were rendered inside the scrollable body. Moved them
  into the Modal's footer slot.
2026-04-21 21:36:43 +02:00
Julien G. fccf13a7e2 Merge pull request #797 from mauriceboe/fix/786-copy-trip-todos-budget-order
fix(trips): copy todo_items and budget_category_order when duplicating a trip
2026-04-21 20:51:18 +02:00
jubnl 09431f725c feat(dashboard): add pre-copy confirmation modal showing what will and won't be copied
Introduces CopyTripDialog — a two-section modal that appears before the
copy action and lists what is carried over (days, places, budget items,
packing lists, TODOs, notes) and what is intentionally skipped
(collaborators, collab data, files, share tokens). Addresses the UX gap
raised in #786.
2026-04-21 20:45:23 +02:00
jubnl 13162c0920 fix(trips): copy todo_items and budget_category_order when duplicating a trip
Both tables were added after the original copy logic in #270 and were
silently omitted on copy. todo_items are copied with checked reset to 0
and assigned_user_id nulled; budget_category_order rows are copied verbatim.
Adds TRIP-027 regression test.

Closes #786
2026-04-21 20:38:53 +02:00
Julien G. e25b513d0b Merge pull request #793 from mauriceboe/fix/atlas-bucket-list-ui-overflow
fix(atlas): constrain bucket list width to prevent panel overflow
2026-04-21 20:28:58 +02:00
jubnl 9012bffabc fix(atlas): constrain bucket list width to prevent panel overflow
With 30+ bucket list entries the panel expanded to near-full viewport
width, elongating the Stats tab, hiding overflow entries, and covering
the Leaflet zoom controls. Measure the stats content width via
ResizeObserver and use it as maxWidth on the horizontal bucket row so
scroll activates exactly when entries exceed the stats panel width.

Also fixes the ResizeObserver test mock to use a class (matching the
IntersectionObserver pattern) so the instance methods are accessible.

Closes #787
2026-04-21 20:21:40 +02:00
jubnl 24a85b0f91 fix(reservations): clear location when accommodation place is removed
When hotel_place_id is cleared in the modal, also clear the location
field that was auto-filled from the place. Location is hidden for hotel
type so users had no way to remove the stale address after unlinking.
2026-04-21 19:54:43 +02:00
jubnl 43a503b593 fix(reservations): always update place_id when saving hotel accommodation
When clearing the accommodation place from a hotel reservation, the
update branch that runs without a place_id omitted the column from its
UPDATE statement, leaving the old place linked in day_accommodations.
Collapse the two branches into one that always writes place_id (null or value).
2026-04-21 19:51:44 +02:00
jubnl a81fe3da0a fix(reservations): clear editingReservation after successful save
When a reservation was saved, only setShowReservationModal(false) was
called. The modal's useEffect watches [reservation, isOpen, ...], so
flipping isOpen to false re-ran the effect with the stale editingReservation
(old assignment_id), resetting the form to the pre-edit state during the
closing animation. Users perceived this as the value reverting after save.

Calling setEditingReservation(null) immediately after the close mirrors
the existing onClose handler and prevents the stale-prop form reset.
2026-04-21 18:52:24 +02:00
jubnl 70ba4d5435 fix(reservations): show day date range on accommodation cards
Hotel reservations store their date range in day_accommodations rather
than on reservation_time, so the card date block never rendered. Pull
accommodation_start_day_id / accommodation_end_day_id from the SQL join
and surface them on the card.

Also apply Maurice's badge-pill pattern (day name + localized date pill)
to the day-range display, consistent with the modal day selectors.
2026-04-21 18:12:40 +02:00
jubnl 881b9d0939 chore: add troubleshooting in bug report template 2026-04-21 17:25:59 +02:00
jubnl 758de855bf docs: more common issues in troubleshooting 2026-04-21 17:22:09 +02:00
jubnl 9652874bbd fix: update dockerignore and gitignore 2026-04-21 17:02:49 +02:00
jubnl 840f5e82aa docs: update contributing wiki page 2026-04-21 16:57:38 +02:00
jubnl d59b3334dc docs(wiki): add Contributing and Development-environment to sidebar and cross-links 2026-04-21 16:52:38 +02:00
Maurice 5a64d8994e Merge pull request #785 from mauriceboe/fix/synology-cached-thumbnail-size
fix: bump synology cached thumbnail size sm->m (#782)
2026-04-21 15:28:56 +02:00
Maurice e6222894e9 fix: bump synology cached thumbnail size sm->m (#782)
fetchSynologyThumbnailBytes was still serving 240px while the
uncached streamSynologyAsset path had been bumped to 320px in
#761. Align the cached path with the streaming default.
2026-04-21 15:21:58 +02:00
99 changed files with 2983 additions and 877 deletions
+4
View File
@@ -30,3 +30,7 @@ sonar-project.properties
server/tests/ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js server/reset-admin.js
**/*.test.ts
wiki/
scripts/
charts/
+2
View File
@@ -12,6 +12,8 @@ body:
required: true required: true
- label: I am running the latest available version of TREK - label: I am running the latest available version of TREK
required: true required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input - type: input
id: version id: version
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki - name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5 uses: Andrew-Chen-Wang/github-wiki-action@v5
with: with:
strategy: init strategy: clone
+2 -1
View File
@@ -60,4 +60,5 @@ coverage
.scannerwork .scannerwork
test-data test-data
.run .run
.full-review
+29 -13
View File
@@ -6,19 +6,29 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" /> <img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture> </picture>
### Your trips. Your plan. Your server. <br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br /> <br />
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a> <a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a> <a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br /> <br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a> <a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
@@ -117,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance - **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Journey** — magazine-style travel journal with entries, photos, maps, moods - **Naver List Import** — one-click import from shared Naver Maps lists
- **Dashboard widgets** — currency converter and timezone clocks - **MCP** — expose TREK to AI assistants via OAuth 2.1
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
#### 🤖 AI / MCP #### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources - **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups - **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited - **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` - **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -142,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID - **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -162,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. The first user to register becomes admin. Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center"> <div align="center">
@@ -328,7 +342,8 @@ server {
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m; # 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -345,6 +360,7 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400;
} }
} }
``` ```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 2.9.14 version: 3.0.3
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "2.9.14" appVersion: "3.0.3"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.3",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+5 -1
View File
@@ -356,9 +356,13 @@ export const journeyApi = {
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
+16 -23
View File
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
label: string label: string
mood?: string | null mood?: string | null
time: string time: string
dayColor: string
dayLabel: number
} }
export interface JourneyMapHandle { export interface JourneyMapHandle {
@@ -24,6 +26,8 @@ interface MapEntry {
title?: string | null title?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry', label: e.title || 'Entry',
mood: e.mood, mood: e.mood,
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28 const MARKER_W = 28
const MARKER_H = 36 const MARKER_H = 36
function markerSvg(index: number, highlighted: boolean, dark: boolean): string { function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
// Highlighted: inverted colors for contrast (black on light, white on dark) const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? (dark ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(index + 1) const label = String(dayLabel)
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center"> return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/> <circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg> </svg>
</div>` </div>`
} }
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(prev) const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev) const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, false, isDark), html: markerSvg(item.dayColor, item.dayLabel, false),
})) }))
marker.setZIndexOffset(0) marker.setZIndexOffset(0)
} }
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, true, isDark), html: markerSvg(item.dayColor, item.dayLabel, true),
})) }))
marker.setZIndexOffset(1000) marker.setZIndexOffset(1000)
} }
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(i, false, !!dark), html: markerSvg(item.dayColor, item.dayLabel, false),
}) })
const marker = L.marker(pos, { icon }).addTo(map) const marker = L.marker(pos, { icon }).addTo(map)
@@ -14,6 +14,8 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
+16 -17
View File
@@ -18,6 +18,8 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -39,6 +41,8 @@ interface Item {
label: string label: string
locationName: string locationName: string
time: string time: string
dayColor: string
dayLabel: number
} }
const MARKER_W = 28 const MARKER_W = 28
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
label: e.title || '', label: e.title || '',
locationName: e.location_name || '', locationName: e.location_name || '',
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
document.head.appendChild(s) document.head.appendChild(s)
} }
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement { function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dark const fill = dayColor
? (highlighted ? '#FAFAFA' : '#A1A1AA') const textColor = '#fff'
: (highlighted ? '#18181B' : '#52525B') const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? (dark ? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
const label = String(index + 1) const label = String(dayLabel)
// Outer wrap holds the element mapbox positions via `transform: translate(...)`. // Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise // Anything animated (scale, filter) has to live on an inner child — otherwise
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
inner.className = 'trek-journey-marker-inner' inner.className = 'trek-journey-marker-inner'
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/> <circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>` </svg>`
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
if (!item || !marker) return if (!item || !marker) return
const idx = itemsRef.current.indexOf(item)
const el = marker.getElement() const el = marker.getElement()
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
if (!currentInner) return if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText` // Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker. // would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(idx, highlighted, !!darkRef.current) const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML currentInner.innerHTML = nextInner.innerHTML
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
} }
// markers // markers
items.forEach((item, i) => { items.forEach((item) => {
const el = markerHtml(i, false, !!darkRef.current) const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
@@ -1,4 +1,5 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = { const MOOD_ICONS: Record<string, typeof Smile> = {
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
interface Props { interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
index: number dayLabel: number
dayColor: string
isActive: boolean isActive: boolean
onClick: () => void onClick: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
} }
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng) const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0 const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null const firstPhoto = hasPhotos ? entry.photos![0] : null
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
<div className="flex-1 p-3 flex flex-col min-w-0"> <div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */} {/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0"> <span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
{index + 1} {dayLabel}
</span> </span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span> <span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && ( {entry.entry_time && (
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
{hasLocation ? ( {hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name || 'On the map'}</span> <span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
</span> </span>
) : ( ) : (
<span className="text-[10px] text-zinc-400 italic">No location</span> <span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,6 +6,7 @@ import {
ThumbsUp, ThumbsDown, ChevronDown, ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -24,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -84,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0])} src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -101,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail')} src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
@@ -130,7 +133,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
<div className="mb-3"> <div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" /> <MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{entry.location_name} {formatLocationName(entry.location_name)}
</span> </span>
</div> </div>
)} )}
@@ -1,9 +1,10 @@
import { useRef, useState, useEffect, useCallback } from 'react' import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap' import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard' import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap' import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
interface MapEntry { interface MapEntry {
id: string id: string
@@ -23,6 +24,7 @@ interface Props {
onEntryClick: (entry: any) => void onEntryClick: (entry: any) => void
onAddEntry?: () => void onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
} }
export default function MobileMapTimeline({ export default function MobileMapTimeline({
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
onEntryClick, onEntryClick,
onAddEntry, onAddEntry,
publicPhotoUrl, publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) { }: Props) {
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null) const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map) // Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => { const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index] const entry = entries[index]
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
}) })
}, [syncMapToCarousel]) }, [syncMapToCarousel])
// Track scroll; debounce to re-center the active card when the user stops. // Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => { useEffect(() => {
const el = carouselRef.current const el = carouselRef.current
if (!el || entries.length === 0) return if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null let settleTimer: number | null = null
const onScroll = () => { const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(() => { settleTimer = window.setTimeout(pickNearestCard, 150)
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
} }
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => { return () => {
el.removeEventListener('scroll', onScroll) el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
} }
}, [entries.length, pickNearestCard]) }, [entries.length, pickNearestCard])
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}> <div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
entries={mapEntries} entries={mapEntries}
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
} }
return ( return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}> <div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */} {/* Full-screen map */}
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
{/* Bottom carousel */} {/* Bottom carousel */}
<div <div
className="fixed left-0 right-0 z-40" className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }} style={{ touchAction: 'pan-x', bottom: carouselBottom }}
> >
<div <div
ref={carouselRef} ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth" className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
style={{ style={{
scrollSnapType: 'x proximity', scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none', msOverflowStyle: 'none',
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
> >
<MobileEntryCard <MobileEntryCard
entry={entry} entry={entry}
index={i} dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
isActive={i === activeIndex} isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)} onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl} publicPhotoUrl={publicPhotoUrl}
@@ -0,0 +1,32 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
+15 -1
View File
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } }) navigate('/login', { state: { noRedirect: true } })
} }
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null)
useEffect(() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = null
}
}, [])
const toggleDarkMode = () => { const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning') document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
window.setTimeout(() => { if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning') document.documentElement.classList.remove('trek-theme-transitioning')
themeTransitionTimer.current = null
}, 360) }, 360)
} }
+26 -20
View File
@@ -1,11 +1,15 @@
/** /**
* OfflineBanner persistent top bar indicating connectivity + sync state. * OfflineBanner connectivity + sync state indicator.
* *
* States: * States:
* offline + N queued amber bar "Offline N changes queued" * offline + N queued amber pill "Offline · N queued"
* offline + 0 queued amber bar "Offline" * offline + 0 queued amber pill "Offline"
* online + N pending blue bar "Syncing N changes…" * online + N pending blue pill "Syncing N…"
* online + 0 pending hidden * online + 0 pending hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react' import { WifiOff, RefreshCw } from 'lucide-react'
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
const label = offline const label = offline
? pendingCount > 0 ? pendingCount > 0
? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued` ? `Offline · ${pendingCount} queued`
: 'Offline' : 'Offline'
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}` : `Syncing ${pendingCount}`
return ( return (
<div <div
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite" aria-live="polite"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, // Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
left: 0, // so the pill sits 16px from the bottom.
right: 0, bottom: 'calc(var(--bottom-nav-h) + 16px)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999, zIndex: 9999,
background: bg, background: bg,
color: text, color: text,
display: 'flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: 6,
gap: 8, padding: '6px 14px',
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)', borderRadius: 999,
paddingBottom: '6px', boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
paddingLeft: '16px', fontSize: 12,
paddingRight: '16px', fontWeight: 600,
fontSize: 13, whiteSpace: 'nowrap',
fontWeight: 500, pointerEvents: 'none',
}} }}
> >
{offline {offline
? <WifiOff size={14} /> ? <WifiOff size={12} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
} }
{label} {label}
</div> </div>
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
canEdit?: boolean canEdit?: boolean
} }
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
const PACKING_PLACEHOLDER_NAME = '...'
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name) const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false)
@@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => { const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(item.name); return } if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) } catch { toast.error(t('packing.toast.saveError')) }
} }
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{editing && canEdit ? ( {editing && canEdit ? (
<input <input
type="text" value={editName} autoFocus type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName} onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/> />
) : ( ) : (
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{ style={{
flex: 1, fontSize: 13.5, flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text', cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)', color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none', textDecoration: item.checked ? 'line-through' : 'none',
}} }}
@@ -168,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}> <div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -360,6 +360,25 @@ export default function PlaceFormModal({
onClose={onClose} onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')} title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg" size="lg"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
}
> >
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}> <form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */} {/* Place Search */}
@@ -613,23 +632,6 @@ export default function PlaceFormModal({
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } }); fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } }); fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly // When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!; // The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
fireEvent.submit(form); fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
@@ -271,7 +271,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl"> <Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */} {/* Type selector */}
@@ -417,12 +432,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<CustomSelect <CustomSelect
value={form.hotel_place_id} value={form.hotel_place_id}
onChange={value => { onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value) const p = places.find(pl => pl.id === value)
if (p) { setForm(prev => {
if (!form.title) set('title', p.name) const next = { ...prev, hotel_place_id: value }
if (!form.location && p.address) set('location', p.address) if (!value) {
} next.location = ''
} else if (p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}} }}
placeholder={t('reservations.meta.pickHotel')} placeholder={t('reservations.meta.pickHotel')}
options={[ options={[
@@ -617,15 +637,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</> </>
)} )}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -112,17 +112,30 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type) const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined const isHotel = r.type === 'hotel'
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined const startDay = r.day_id ? days.find(d => d.id === r.day_id)
const dayLabel = (day: typeof startDay): string => { : (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
if (!day) return '' : undefined
const base = day.title || t('dayplan.dayN', { n: day.day_number }) const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
if (day.date) { : (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
const d = new Date(day.date + 'T00:00:00Z') : undefined
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) const DayLabel = ({ day }: { day: typeof startDay }) => {
return `${base} · ${dateStr}` if (!day) return null
} const name = day.title || t('dayplan.dayN', { n: day.day_number })
return base const badge = day.date
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span>{name}</span>
{badge && (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
)}
</span>
)
} }
return ( return (
@@ -135,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
> >
{/* Header */} {/* Header wraps to a second row on narrow screens so the status/category chips
never collide with the title. */}
<div style={{ <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
flexWrap: 'wrap',
padding: '12px 14px', padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}> }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706', fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
@@ -202,12 +217,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */} {/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}> <div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport reservations linked to a day */} {/* Day label for transport/hotel reservations linked to days */}
{isTransportType && startDay && ( {(isTransportType || isHotel) && startDay && (
<div> <div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> <div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''} <DayLabel day={startDay} />
{endDay && endDay.id !== startDay.id && (
<><span style={{ color: 'var(--text-faint)' }}></span><DayLabel day={endDay} /></>
)}
</div> </div>
</div> </div>
)} )}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react' import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types' import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -75,6 +77,8 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -94,6 +98,13 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '', meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined }) setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
} }
}, [isOpen, reservation, selectedDayId]) }, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat if (form.meta_seat) metadata.seat = form.meta_seat
} }
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, needs_review: false,
} }
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload) await onSave(payload)
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -237,6 +259,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onClose={onClose} onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl" size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
> >
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -412,15 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* Actions */} {/* Price + Budget Category */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}> {isBudgetEnabled && (
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> <>
{t('common.cancel')} <div style={{ display: 'flex', gap: 8 }}>
</button> <div style={{ flex: 1, minWidth: 0 }}>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}> <label style={labelStyle}>{t('reservations.price')}</label>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')} <input type="text" inputMode="decimal" value={form.price}
</button> onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
</div> onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form> </form>
</Modal> </Modal>
) )
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined); const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting }); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
await user.click(screen.getByText('24h (14:30)')); // The label is split across a text node ('24h') and a responsive span (' (14:30)').
// Click the button that contains the 24h text instead of matching the full string.
await user.click(screen.getByRole('button', { name: /24h/ }));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
}); });
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3"> <div className="flex gap-3">
{[ {[
{ value: '24h', label: '24h (14:30)' }, { value: '24h', short: '24h', example: '14:30' },
{ value: '12h', label: '12h (2:30 PM)' }, { value: '12h', short: '12h', example: '2:30 PM' },
].map(opt => ( ].map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{opt.label} {opt.short}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
</button> </button>
))} ))}
</div> </div>
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700' : 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`} }`}
> >
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" /> <Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div> <div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div> <div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div> <div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div> </div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
</button> </button>
</div> </div>
<p className="text-xs text-slate-400 mt-2"> <p className="text-xs text-slate-400 mt-2">
@@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
interface ProviderField { interface ProviderField {
key: string key: string
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? ( {field.input_type === 'checkbox' ? (
<label className="flex items-center gap-2 cursor-pointer select-none"> <div className="flex items-center gap-3">
<input <ToggleSwitch
type="checkbox" on={values[field.key] === 'true'}
checked={values[field.key] === 'true'} onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span> <span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</label> </div>
) : ( ) : (
<> <>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
)} )}
</div> </div>
))} ))}
<div className="flex items-center gap-3"> {/* Wraps on mobile so the connection badge drops to its own row
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<button <button
onClick={() => handleSaveProvider(provider)} onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)} disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
@@ -266,15 +267,17 @@ export default function PhotoProvidersSection(): React.ReactElement {
{testing {testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />} : <Camera className="w-4 h-4" />}
{t('memories.testConnection')} <span className="sm:hidden">{t('memories.testShort')}</span>
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
</button> </button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? ( {connected ? (
<span className="text-xs font-medium text-green-600 flex items-center gap-1"> <span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" /> <span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')} {t('memories.connected')}
</span> </span>
) : ( ) : (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1"> <span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" /> <span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')} {t('memories.disconnected')}
</span> </span>
@@ -2,9 +2,10 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return ( return (
<button onClick={onToggle} <button type="button" onClick={onToggle}
style={{ style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer', position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)', background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s', transition: 'background 0.2s',
}}> }}>
@@ -0,0 +1,108 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
@@ -119,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
...(() => { ...(() => {
const r = ref.current?.getBoundingClientRect() const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 } if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8 const w = 268, pad = 8, h = 360
const vw = window.innerWidth const vw = window.innerWidth
const vh = window.innerHeight const vh = window.visualViewport?.height ?? window.innerHeight
let left = r.left let left = r.left
let top = r.bottom + 4 let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + 320 > vh) top = Math.max(pad, r.top - 320) if (top + h > vh - pad) top = r.top - h - 4
top = Math.max(pad, Math.min(top, vh - h - pad))
if (vw < 360) left = Math.max(pad, (vw - w) / 2) if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left } return { top, left }
})(), })(),
+9 -8
View File
@@ -61,14 +61,15 @@ export default function Modal({
<div <div
className={` className={`
trek-modal-enter trek-modal-enter
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)] flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
`} `}
style={{ background: 'var(--bg-card)' }} style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Header */} {/* Header — stays put even while the body scrolls */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2> <h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && ( {!hideCloseButton && (
<button <button
@@ -80,14 +81,14 @@ export default function Modal({
)} )}
</div> </div>
{/* Body */} {/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6 min-h-0">
{children} {children}
</div> </div>
{/* Footer */} {/* Footer — sticky at the bottom of the modal, never compressed */}
{footer && ( {footer && (
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}> <div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer} {footer}
</div> </div>
)} )}
+3
View File
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء', 'common.none': 'لا شيء',
'common.date': 'التاريخ', 'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية', 'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم', 'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني', 'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
@@ -1623,6 +1625,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع', 'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo', 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال', 'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل', 'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل', 'memories.disconnected': 'غير متصل',
+3
View File
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nenhum', 'common.none': 'Nenhum',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Renomear', 'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Senha', 'common.password': 'Senha',
@@ -1662,6 +1664,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar', 'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão', 'memories.testConnection': 'Testar conexão',
'memories.testShort': 'Testar',
'memories.testFirst': 'Teste a conexão primeiro', 'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado', 'memories.disconnected': 'Não conectado',
+3
View File
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Žádné', 'common.none': 'Žádné',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Přejmenovat', 'common.rename': 'Přejmenovat',
'common.discardChanges': 'Zahodit změny',
'common.discard': 'Zahodit',
'common.name': 'Jméno', 'common.name': 'Jméno',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Heslo', 'common.password': 'Heslo',
@@ -1621,6 +1623,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich', 'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení', 'memories.testConnection': 'Otestovat připojení',
'memories.testShort': 'Otestovat',
'memories.testFirst': 'Nejprve otestujte připojení', 'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno', 'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno', 'memories.disconnected': 'Nepřipojeno',
+3
View File
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Keine', 'common.none': 'Keine',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Umbenennen', 'common.rename': 'Umbenennen',
'common.discardChanges': 'Änderungen verwerfen',
'common.discard': 'Verwerfen',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
@@ -1625,6 +1627,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln', 'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen', 'memories.testConnection': 'Verbindung testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Verbindung zuerst testen', 'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden', 'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden', 'memories.disconnected': 'Nicht verbunden',
+17
View File
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'None', 'common.none': 'None',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Rename', 'common.rename': 'Rename',
'common.discardChanges': 'Discard Changes',
'common.discard': 'Discard',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
@@ -122,6 +124,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.copied': 'Trip copied!', 'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip', 'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.', 'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.confirm.copy.title': 'Copy this trip?',
'dashboard.confirm.copy.willCopy': 'Will be copied',
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
'dashboard.confirm.copy.will3': 'Budget items & category order',
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
'dashboard.confirm.copy.will6': 'Day notes',
'dashboard.confirm.copy.wontCopy': "Won't be copied",
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
'dashboard.confirm.copy.wont3': 'Files & photos',
'dashboard.confirm.copy.wont4': 'Share tokens',
'dashboard.confirm.copy.confirm': 'Copy trip',
'dashboard.editTrip': 'Edit Trip', 'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip', 'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title', 'dashboard.tripTitle': 'Title',
@@ -1684,6 +1700,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload', 'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection', 'memories.testConnection': 'Test connection',
'memories.testShort': 'Test',
'memories.testFirst': 'Test connection first', 'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected', 'memories.connected': 'Connected',
'memories.disconnected': 'Not connected', 'memories.disconnected': 'Not connected',
+3
View File
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
'common.none': 'Ninguno', 'common.none': 'Ninguno',
'common.date': 'Fecha', 'common.date': 'Fecha',
'common.rename': 'Renombrar', 'common.rename': 'Renombrar',
'common.discardChanges': 'Descartar cambios',
'common.discard': 'Descartar',
'common.name': 'Nombre', 'common.name': 'Nombre',
'common.email': 'Correo', 'common.email': 'Correo',
'common.password': 'Contraseña', 'common.password': 'Contraseña',
@@ -1562,6 +1564,7 @@ const es: Record<string, string> = {
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas', 'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión', 'memories.testConnection': 'Probar conexión',
'memories.testShort': 'Probar',
'memories.testFirst': 'Probar conexión primero', 'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado', 'memories.disconnected': 'No conectado',
+3
View File
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
'common.none': 'Aucun', 'common.none': 'Aucun',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Renommer', 'common.rename': 'Renommer',
'common.discardChanges': 'Ignorer les modifications',
'common.discard': 'Ignorer',
'common.name': 'Nom', 'common.name': 'Nom',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Mot de passe', 'common.password': 'Mot de passe',
@@ -1619,6 +1621,7 @@ const fr: Record<string, string> = {
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement', 'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion', 'memories.testConnection': 'Tester la connexion',
'memories.testShort': 'Tester',
'memories.testFirst': 'Testez la connexion avant de sauvegarder', 'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté', 'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté', 'memories.disconnected': 'Non connecté',
+3
View File
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nincs', 'common.none': 'Nincs',
'common.date': 'Dátum', 'common.date': 'Dátum',
'common.rename': 'Átnevezés', 'common.rename': 'Átnevezés',
'common.discardChanges': 'Változtatások elvetése',
'common.discard': 'Elveti',
'common.name': 'Név', 'common.name': 'Név',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Jelszó', 'common.password': 'Jelszó',
@@ -1690,6 +1692,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is', 'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése', 'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testShort': 'Teszt',
'memories.testFirst': 'Először teszteld a kapcsolatot', 'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva', 'memories.connected': 'Csatlakoztatva',
'memories.disconnected': 'Nincs csatlakoztatva', 'memories.disconnected': 'Nincs csatlakoztatva',
+3
View File
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Tidak ada', 'common.none': 'Tidak ada',
'common.date': 'Tanggal', 'common.date': 'Tanggal',
'common.rename': 'Ganti nama', 'common.rename': 'Ganti nama',
'common.discardChanges': 'Buang perubahan',
'common.discard': 'Buang',
'common.name': 'Nama', 'common.name': 'Nama',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Kata sandi', 'common.password': 'Kata sandi',
@@ -1682,6 +1684,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah', 'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
'memories.testConnection': 'Uji koneksi', 'memories.testConnection': 'Uji koneksi',
'memories.testShort': 'Uji',
'memories.testFirst': 'Uji koneksi terlebih dahulu', 'memories.testFirst': 'Uji koneksi terlebih dahulu',
'memories.connected': 'Terhubung', 'memories.connected': 'Terhubung',
'memories.disconnected': 'Tidak terhubung', 'memories.disconnected': 'Tidak terhubung',
+3
View File
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nessuno', 'common.none': 'Nessuno',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Rinomina', 'common.rename': 'Rinomina',
'common.discardChanges': 'Scarta modifiche',
'common.discard': 'Scarta',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
@@ -1620,6 +1622,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento', 'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione', 'memories.testConnection': 'Test connessione',
'memories.testShort': 'Prova',
'memories.testFirst': 'Testa prima la connessione', 'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso', 'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso', 'memories.disconnected': 'Non connesso',
+7 -4
View File
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
'common.none': 'Geen', 'common.none': 'Geen',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Hernoemen', 'common.rename': 'Hernoemen',
'common.discardChanges': 'Wijzigingen verwerpen',
'common.discard': 'Verwerpen',
'common.name': 'Naam', 'common.name': 'Naam',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Wachtwoord', 'common.password': 'Wachtwoord',
@@ -612,8 +614,8 @@ const nl: Record<string, string> = {
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking', 'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
'admin.collab.notes.title': 'Notities', 'admin.collab.notes.title': 'Notities',
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten', 'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
'admin.collab.polls.title': 'Peilingen', 'admin.collab.polls.title': 'Polls',
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen', 'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
'admin.collab.whatsnext.title': 'Wat nu', 'admin.collab.whatsnext.title': 'Wat nu',
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen', 'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
'admin.tabs.config': 'Personalisatie', 'admin.tabs.config': 'Personalisatie',
@@ -1619,6 +1621,7 @@ const nl: Record<string, string> = {
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen', 'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen', 'memories.testConnection': 'Verbinding testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Test eerst de verbinding', 'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden', 'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden', 'memories.disconnected': 'Niet verbonden',
@@ -1658,7 +1661,7 @@ const nl: Record<string, string> = {
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities', 'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Peilingen', 'collab.tabs.polls': 'Polls',
'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen', 'collab.whatsNext.tomorrow': 'Morgen',
@@ -1704,7 +1707,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen', 'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën', 'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen', 'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Peilingen', 'collab.polls.title': 'Polls',
'collab.polls.new': 'Nieuwe poll', 'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls', 'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen', 'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
+3
View File
@@ -26,6 +26,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Brak', 'common.none': 'Brak',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Zmień nazwę', 'common.rename': 'Zmień nazwę',
'common.discardChanges': 'Odrzuć zmiany',
'common.discard': 'Odrzuć',
'common.name': 'Nazwa', 'common.name': 'Nazwa',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Hasło', 'common.password': 'Hasło',
@@ -1571,6 +1573,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich', 'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test', 'memories.testConnection': 'Test',
'memories.testShort': 'Test',
'memories.connected': 'Połączono', 'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono', 'memories.disconnected': 'Nie połączono',
'memories.connectionSuccess': 'Połączono z Immich', 'memories.connectionSuccess': 'Połączono z Immich',
+3
View File
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
'common.none': 'Нет', 'common.none': 'Нет',
'common.date': 'Дата', 'common.date': 'Дата',
'common.rename': 'Переименовать', 'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.name': 'Имя', 'common.name': 'Имя',
'common.email': 'Эл. почта', 'common.email': 'Эл. почта',
'common.password': 'Пароль', 'common.password': 'Пароль',
@@ -1619,6 +1621,7 @@ const ru: Record<string, string> = {
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке', 'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение', 'memories.testConnection': 'Проверить подключение',
'memories.testShort': 'Проверить',
'memories.testFirst': 'Сначала проверьте подключение', 'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено', 'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено', 'memories.disconnected': 'Не подключено',
+3
View File
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
'common.none': '无', 'common.none': '无',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重命名', 'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.name': '名称', 'common.name': '名称',
'common.email': '邮箱', 'common.email': '邮箱',
'common.password': '密码', 'common.password': '密码',
@@ -1619,6 +1621,7 @@ const zh: Record<string, string> = {
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich', 'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo', 'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接', 'memories.testConnection': '测试连接',
'memories.testShort': '测试',
'memories.testFirst': '请先测试连接', 'memories.testFirst': '请先测试连接',
'memories.connected': '已连接', 'memories.connected': '已连接',
'memories.disconnected': '未连接', 'memories.disconnected': '未连接',
+3
View File
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
'common.none': '無', 'common.none': '無',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重新命名', 'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.name': '名稱', 'common.name': '名稱',
'common.email': '郵箱', 'common.email': '郵箱',
'common.password': '密碼', 'common.password': '密碼',
@@ -1679,6 +1681,7 @@ const zhTw: Record<string, string> = {
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich', 'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo', 'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線', 'memories.testConnection': '測試連線',
'memories.testShort': '測試',
'memories.testFirst': '請先測試連線', 'memories.testFirst': '請先測試連線',
'memories.connected': '已連線', 'memories.connected': '已連線',
'memories.disconnected': '未連線', 'memories.disconnected': '未連線',
+11 -2
View File
@@ -1240,6 +1240,15 @@ interface SidebarContentProps {
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 { function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation() const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
ro.observe(el)
return () => ro.disconnect()
}, [])
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a' const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b' const tm = dark ? '#94a3b8' : '#64748b'
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// Bucket list content // Bucket list content
const bucketContent = ( const bucketContent = (
<> <>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}> <div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
{bucketList.map(item => ( {bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}> <div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => { {(() => {
@@ -1400,7 +1409,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{/* Both tabs always rendered so the wider one sets the panel width */} {/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}> <div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}> <div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div className="flex items-stretch justify-center"> <div ref={statsContentRef} className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */} {/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */} {/* Countries hero */}
+8
View File
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
const copyButtons = screen.getAllByRole('button', { name: /copy/i }); const copyButtons = screen.getAllByRole('button', { name: /copy/i });
await user.click(copyButtons[0]); await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument(); expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
}); });
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
expect(copyButtons.length).toBeGreaterThan(0); expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]); await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0); expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
}); });
+15 -2
View File
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget' import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal' import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog' import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp' import { useCountUp } from '../hooks/useCountUp'
import { import {
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false) const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null) const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => { const toggleViewMode = () => {
setViewMode(prev => { setViewMode(prev => {
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update)) setArchivedTrips(prev => prev.map(update))
} }
const handleCopy = async (trip: DashboardTrip) => { const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
const confirmCopy = async () => {
if (!copyTrip) return
try { try {
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` }) const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev])) setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied')) toast.success(t('dashboard.toast.copied'))
} catch { } catch {
toast.error(t('dashboard.toast.copyError')) toast.error(t('dashboard.toast.copyError'))
} }
setCopyTrip(null)
} }
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })} message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/> />
<CopyTripDialog
isOpen={!!copyTrip}
tripTitle={copyTrip?.title || ''}
onClose={() => setCopyTrip(null)}
onConfirm={confirmCopy}
/>
<style>{` <style>{`
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1 } 0%, 100% { opacity: 1 }
+42 -28
View File
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
}, },
], ],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [
{
id: 100,
journey_id: 1,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
owner_id: null,
thumbnail_path: null,
caption: 'Colosseum',
sort_order: 0,
width: 800,
height: 600,
shared: 1,
created_at: now,
},
],
}; };
// ── MSW Handlers ───────────────────────────────────────────────────────────── // ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -1468,7 +1486,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-074 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => { describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
it('clicking "Remove share link" calls DELETE and returns to create state', async () => { it('clicking "Delete link" calls DELETE and returns to create state', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let deleteCalled = false; let deleteCalled = false;
@@ -1493,10 +1511,10 @@ describe('JourneyDetailPage', () => {
await openSettingsDialog(user); await openSettingsDialog(user);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Remove share link')).toBeInTheDocument(); expect(screen.getByText('Delete link')).toBeInTheDocument();
}); });
await user.click(screen.getByText('Remove share link')); await user.click(screen.getByText('Delete link'));
await waitFor(() => { await waitFor(() => {
expect(deleteCalled).toBe(true); expect(deleteCalled).toBe(true);
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => { it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos // Override with entries that have no photos and empty gallery
const emptyEntry = { const emptyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [], photos: [],
}; };
setupDefaultHandlers({ setupDefaultHandlers({
entries: [emptyEntry], entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 }, stats: { entries: 1, photos: 0, places: 1 },
}); });
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo // Gallery photos render in a grid; each photo has a group container
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US const photos = document.querySelectorAll('[class*="aspect-square"]');
const dateOverlay = document.querySelector('[class*="opacity-0"]'); expect(photos.length).toBeGreaterThanOrEqual(1);
expect(dateOverlay).toBeTruthy();
}); });
}); });
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]], entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]], entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2905,7 +2933,7 @@ describe('JourneyDetailPage', () => {
// The permission toggles show Timeline, Gallery, Map labels within the share section // The permission toggles show Timeline, Gallery, Map labels within the share section
// These reuse the same i18n keys as the main tab bar // These reuse the same i18n keys as the main tab bar
expect(screen.getByText('Remove share link')).toBeInTheDocument(); expect(screen.getByText('Delete link')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument(); expect(screen.getByText('Copy')).toBeInTheDocument();
}); });
}); });
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery creates an entry and uploads photos', async () => { it('uploading files in gallery calls gallery upload API', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false; let uploadCalled = false;
server.use( server.use(
http.post('/api/journeys/1/entries', () => { http.post('/api/journeys/1/gallery/photos', () => {
createCalled = true;
return HttpResponse.json({
id: 99, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.post('/api/journeys/entries/99/photos', () => {
uploadCalled = true; uploadCalled = true;
return HttpResponse.json([]); return HttpResponse.json({ photos: [] });
}), }),
); );
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile); await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => { await waitFor(() => {
expect(uploadCalled).toBe(true); expect(uploadCalled).toBe(true);
}); });
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/journeys/photos/100', () => { http.delete('/api/journeys/1/gallery/100', () => {
deleteCalled = true; deleteCalled = true;
return HttpResponse.json({ success: true }); return new HttpResponse(null, { status: 204 });
}), }),
); );
+133 -106
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket' import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto' import JourneyMap from '../components/Journey/JourneyMapAuto'
import { DAY_COLORS } from '../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
@@ -25,7 +27,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView' import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [ const GRADIENTS = [
@@ -67,16 +69,18 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
return groups return groups
} }
function formatDate(d: string): { weekday: string; month: string; day: number } { function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
// instead of the browser's navigator.language.
return { return {
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }), weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }), month: date.toLocaleDateString(locale, { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t } = useTranslation() const { t, locale } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore() const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null) const fullMapRef = useRef<JourneyMapHandle>(null)
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
const winner = lastPast || firstAhead const winner = lastPast || firstAhead
if (winner) { if (winner) {
setActiveEntryId(winner.id) setActiveEntryId(winner.id)
mapRef.current?.highlightMarker(winner.id) if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
} }
} }
const onScroll = () => { const onScroll = () => {
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
[current?.entries] [current?.entries]
) )
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({ const sidebarMapItems = useMemo(() => {
id: String(e.id), const allDates = [...new Set(
lat: e.location_lat!, (current?.entries || [])
lng: e.location_lng!, .filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
title: e.title || '', .map(e => e.entry_date)
location_name: e.location_name || '', .sort()
mood: e.mood, )]
created_at: e.entry_date, const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
entry_date: e.entry_date, const dayCounters = new Map<string, number>()
})), [mapEntries]) return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => { const tripDates = useMemo(() => {
const dates = new Set<string>() const dates = new Set<string>()
@@ -313,7 +341,7 @@ export default function JourneyDetailPage() {
) )
} }
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries) const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
? 'max-w-[1440px] mx-auto px-0 pt-0' ? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden' : 'flex w-full overflow-hidden'
} }
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined} style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
> >
{/* LEFT column (full width on mobile, scrollable feed on desktop) */} {/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div <div
@@ -430,7 +458,7 @@ export default function JourneyDetailPage() {
className={ className={
isMobile isMobile
? '' ? ''
: 'flex-1 overflow-y-auto journey-feed-scroll' : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
} }
> >
<div className={isMobile ? '' : 'w-full px-8 py-6'}> <div className={isMobile ? '' : 'w-full px-8 py-6'}>
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
> >
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />} {hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button> </button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity"> <span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')} {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span> </span>
</div> </div>
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
{sortedDates.map((date, dayIdx) => { {sortedDates.map((date, dayIdx) => {
const entries = dayGroups.get(date)! const entries = dayGroups.get(date)!
const fd = formatDate(date) const fd = formatDate(date, locale)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))] const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return ( return (
<div key={date} className="flex flex-col gap-3 trek-stagger"> <div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between"> <div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold"> <div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
{dayIdx + 1} {dayIdx + 1}
</div> </div>
<div> <div>
@@ -611,7 +639,7 @@ export default function JourneyDetailPage() {
.catch(() => toast.error(t('common.errorOccurred'))) .catch(() => toast.error(t('common.errorOccurred')))
} }
return ( return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}> <div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
{canReorder && ( {canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1"> <div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button <button
@@ -665,10 +693,11 @@ export default function JourneyDetailPage() {
> >
<GalleryView <GalleryView
entries={current.entries} entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id} journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0} userId={useAuthStore.getState().user?.id || 0}
trips={current.trips} trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))} onRefresh={() => loadJourney(Number(id))}
/> />
</div> </div>
@@ -705,7 +734,7 @@ export default function JourneyDetailPage() {
entry={editingEntry} entry={editingEntry}
journeyId={current.id} journeyId={current.id}
tripDates={tripDates} tripDates={tripDates}
galleryPhotos={current.entries.flatMap(e => e.photos || [])} galleryPhotos={current.gallery || []}
onClose={() => setEditingEntry(null)} onClose={() => setEditingEntry(null)}
onSave={async (data) => { onSave={async (data) => {
let entryId = editingEntry.id let entryId = editingEntry.id
@@ -733,7 +762,8 @@ export default function JourneyDetailPage() {
journey={current} journey={current}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }} onOpenInvite={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
/> />
)} )}
@@ -816,7 +846,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
fullMapRef: React.RefObject<JourneyMapHandle | null> fullMapRef: React.RefObject<JourneyMapHandle | null>
onLocationClick: (id: string) => void onLocationClick: (id: string) => void
}) { }) {
const { t } = useTranslation() const { t, locale } = useTranslation()
// group map entries by date // group map entries by date
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>() const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
mapEntries.forEach((e, i) => { mapEntries.forEach((e, i) => {
@@ -872,7 +902,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div className="px-5 pb-5"> <div className="px-5 pb-5">
{dates.map((date, dayIdx) => { {dates.map((date, dayIdx) => {
const items = byDate.get(date)! const items = byDate.get(date)!
const fd = formatDate(date) const fd = formatDate(date, locale)
return ( return (
<div key={date}> <div key={date}>
@@ -915,7 +945,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span> <span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div> </div>
<div className="text-[11px] text-zinc-500 truncate"> <div className="text-[11px] text-zinc-500 truncate">
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''} {formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div> </div>
</div> </div>
@@ -942,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// ── Gallery View ────────────────────────────────────────────────────────── // ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number journeyId: number
userId: number userId: number
trips: JourneyTrip[] trips: JourneyTrip[]
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -980,12 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
})() })()
}, []) }, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const allPhotos = gallery
for (const e of entries) {
for (const p of e.photos) {
allPhotos.push({ photo: p, entry: e })
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1001,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryUploading(true)
try { try {
// find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
}
const formData = new FormData() const formData = new FormData()
for (const f of files) formData.append('photos', f) for (const f of files) formData.append('photos', f)
await journeyApi.uploadPhotos(entryId, formData) await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
@@ -1027,24 +1040,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
e.target.value = '' e.target.value = ''
} }
const handleDeletePhoto = async (photoId: number) => { const handleDeletePhoto = async (galleryPhotoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState() const store = useJourneyStore.getState()
if (store.current) { if (!store.current) return
const updated = {
// Optimistic update — remove from gallery and all entry photo lists
useJourneyStore.setState({
current: {
...store.current, ...store.current,
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
entries: store.current.entries.map(e => ({ entries: store.current.entries.map(e => ({
...e, ...e,
photos: e.photos.filter(p => p.id !== photoId), photos: e.photos.filter(p => p.id !== galleryPhotoId),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), })),
} },
useJourneyStore.setState({ current: updated }) })
}
try { try {
await journeyApi.deletePhoto(photoId) await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() // Revert on error onRefresh()
} }
} }
@@ -1092,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }, i) => ( {allPhotos.map((photo, i) => (
<div <div
key={photo.id} key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group" className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)} onClick={() => onPhotoClick(allPhotos, i)}
> >
<img <img
src={photoUrl(photo, 'thumbnail')} src={photoUrl(photo, 'thumbnail')}
@@ -1125,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
<p className="text-[10px] text-white truncate">{photo.caption}</p> <p className="text-[10px] text-white truncate">{photo.caption}</p>
</div> </div>
)} )}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1142,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
userId={userId} userId={userId}
entries={entriesWithContent} entries={entriesWithContent}
trips={trips} trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => { onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
targetId = entry.id
} catch { return }
}
let added = 0 let added = 0
for (const group of groups) { for (const group of groups) {
try { try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) if (entryId) {
added += result.added || 0 const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0
}
} catch {} } catch {}
} }
if (added > 0) { if (added > 0) {
@@ -1358,7 +1363,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && ( {entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name}</span> <span className="truncate">{formatLocationName(entry.location_name)}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1401,7 +1406,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2"> <div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && ( {entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span> <MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1480,7 +1485,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
{entry.title || t('journey.detail.newEntry')} {entry.title || t('journey.detail.newEntry')}
</div> </div>
<div className="text-[11px] text-zinc-500 mt-0.5"> <div className="text-[11px] text-zinc-500 mt-0.5">
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''} {formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div> </div>
</div> </div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0"> <div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
@@ -1764,11 +1769,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery') : t('journey.picker.newGallery')
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white"> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'} {provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2> </h2>
@@ -1778,7 +1783,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Filter bar */} {/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700"> <div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{[ {[
@@ -1864,7 +1869,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Add-to entry selector */} {/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span> <span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button <button
@@ -1917,7 +1922,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null if (selectable.length === 0) return null
return ( return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900"> <div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<button <button
onClick={() => { onClick={() => {
if (allSelected) { if (allSelected) {
@@ -1942,7 +1947,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
})()} })()}
{/* Photo grid */} {/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
@@ -2015,7 +2020,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span> <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span> <span className="leading-[18px]">{t('journey.picker.selected')}</span>
@@ -2161,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry entry: JourneyEntry
journeyId: number journeyId: number
tripDates: Set<string> tripDates: Set<string>
galleryPhotos: JourneyPhoto[] galleryPhotos: GalleryPhoto[]
onClose: () => void onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number> onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
@@ -2187,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || []) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false) const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2214,6 +2219,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const handleClose = () => { const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose() onClose()
@@ -2323,7 +2330,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{showGalleryPick && ( {showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50"> <div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto"> <div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => ( {availableGalleryPhotos.map(gp => (
<div <div
key={gp.id} key={gp.id}
onClick={async () => { onClick={async () => {
@@ -2343,7 +2350,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> <img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div> </div>
))} ))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( {availableGalleryPhotos.length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div> <div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)} )}
</div> </div>
@@ -2378,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id)) setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
}} }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
@@ -2952,7 +2964,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={deleteLink} onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start" className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
> >
Remove share link {t('share.deleteLink')}
</button> </button>
</div> </div>
)} )}
@@ -2960,11 +2972,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
) )
} }
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
journey: JourneyDetail journey: JourneyDetail
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
onOpenInvite: () => void onOpenInvite: () => void
onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [title, setTitle] = useState(journey.title) const [title, setTitle] = useState(journey.title)
@@ -2972,6 +2985,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null) const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null) const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast() const toast = useToast()
const navigate = useNavigate() const navigate = useNavigate()
@@ -3030,12 +3047,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"> <button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -3131,7 +3148,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
try { try {
await journeyApi.removeContributor(journey.id, c.user_id) await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed')) toast.success(t('journey.contributors.removed'))
onSaved() onRefresh()
} catch { } catch {
toast.error(t('journey.contributors.removeFailed')) toast.error(t('journey.contributors.removeFailed'))
} }
@@ -3182,7 +3199,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />} {journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span> <span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button> </button>
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button> <button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40"> <button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')} {saving ? t('common.saving') : t('common.save')}
</button> </button>
@@ -3229,6 +3246,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
confirmLabel={t('common.delete')} confirmLabel={t('common.delete')}
danger danger
/> />
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div> </div>
) )
} }
+69 -25
View File
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
), ),
})); }));
vi.mock('../components/Journey/MobileMapTimeline', () => ({
default: ({ onEntryClick }: any) => (
<div data-testid="mobile-map-timeline">
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
Open Entry
</button>
</div>
),
}));
const mockIsMobile = { value: false };
vi.mock('../hooks/useIsMobile', () => ({
useIsMobile: () => mockIsMobile.value,
}));
import JourneyPublicPage from './JourneyPublicPage'; import JourneyPublicPage from './JourneyPublicPage';
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -106,6 +121,9 @@ const mockJourneyData = {
share_gallery: true, share_gallery: true,
share_map: true, share_map: true,
}, },
gallery: [
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
],
stats: { stats: {
entries: 2, entries: 2,
photos: 1, photos: 1,
@@ -136,6 +154,7 @@ function setup404() {
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
mockIsMobile.value = false;
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -234,28 +253,20 @@ describe('JourneyPublicPage', () => {
} }
}); });
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => { it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
const buttons = screen.getAllByRole('button'); // Desktop two-column: map sidebar is always rendered alongside the timeline;
const mapBtn = buttons.find( // there is no standalone "Map" tab button on desktop.
btn => btn.textContent && /map/i.test(btn.textContent), await waitFor(() => {
); expect(screen.getByTestId('journey-map')).toBeInTheDocument();
expect(mapBtn).toBeDefined(); });
if (mapBtn) { // Timeline entries remain visible (two-column shows both simultaneously)
fireEvent.click(mapBtn); expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
// After clicking map tab, the timeline entries should no longer be visible
// and the map view content should be rendered (even if JourneyMap errors internally
// due to jsdom limitations, the tab state switches)
await waitFor(() => {
// Shibuya Crossing (timeline-only) should not appear once map is active
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
});
}
}); });
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => { it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
@@ -303,24 +314,18 @@ describe('JourneyPublicPage', () => {
}); });
// FE-PAGE-PUBLICJOURNEY-012 // FE-PAGE-PUBLICJOURNEY-012
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => { it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
const user = userEvent.setup();
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
const mapBtn = screen.getAllByRole('button').find( // Desktop two-column: map sidebar is always rendered; no tab click required.
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument(); expect(screen.getByTestId('journey-map')).toBeInTheDocument();
}); });
// Map receives entries with lat/lng // Both fixture entries have coordinates → map receives 2 located entries
expect(screen.getByTestId('journey-map').textContent).toContain('2'); expect(screen.getByTestId('journey-map').textContent).toContain('2');
}); });
@@ -354,6 +359,11 @@ describe('JourneyPublicPage', () => {
], ],
}, },
], ],
gallery: [
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
],
stats: { entries: 1, photos: 3, places: 0 }, stats: { entries: 1, photos: 3, places: 0 },
}; };
@@ -405,6 +415,40 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7'); expect(statsContainer!.textContent).toContain('7');
}); });
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
expect(mapBtn).toBeUndefined();
});
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
const user = userEvent.setup();
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
const openBtn = screen.getByText('Open Entry');
await user.click(openBtn);
// MobileEntryView should slide in with the entry title
await waitFor(() => {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
});
});
// FE-PAGE-PUBLICJOURNEY-016 // FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+437 -167
View File
@@ -1,14 +1,23 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client' import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react' import {
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap' import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry { interface PublicEntry {
id: number id: number
@@ -36,15 +45,42 @@ interface PublicPhoto {
caption?: string | null caption?: string | null
} }
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
} }
function formatDate(d: string): { weekday: string; month: string; day: number } { function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
return { return {
weekday: date.toLocaleDateString('en', { weekday: 'long' }), weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }), month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
@@ -70,6 +106,16 @@ export default function JourneyPublicPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false) const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
@@ -80,25 +126,45 @@ export default function JourneyPublicPage() {
}, [token]) }, [token])
const entries = (data?.entries || []) as PublicEntry[] const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {} const perms = data?.permissions || {}
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} const stats = data?.stats || {}
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers const timelineEntries = useMemo(() => entries, [entries])
// produced by the trip→journey sync. They have no story and no
// location, and the owner view strips them from the timeline the
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo( const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng), () => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries], [timelineEntries],
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, sortedDates])
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
// Set default view based on permissions // Set default view based on permissions
useEffect(() => { useEffect(() => {
@@ -106,6 +172,11 @@ export default function JourneyPublicPage() {
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map') else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms]) }, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
@@ -125,21 +196,262 @@ export default function JourneyPublicPage() {
) )
} }
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes
const renderTimeline = () => (
<div className="flex flex-col gap-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<BookOpen size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
</div>
)}
{sortedDates.map((date, dayIdx) => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date, locale)
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
return (
<div key={date}>
{/* Day header */}
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
style={{ background: dayColor }}
>
{dayIdx + 1}
</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
{/* Entries */}
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
const isActive = activeEntryId === String(entry.id)
return (
<div
key={entry.id}
data-entry-id={String(entry.id)}
onMouseEnter={() => {
if (!desktopTwoColumn) return
setActiveEntryId(String(entry.id))
mapRef.current?.highlightMarker(String(entry.id))
}}
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{/* Photo area */}
{photos.length === 1 && (
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
{entry.location_name && (
<div className="absolute top-3 left-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
</span>
</div>
)}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
</div>
)}
</div>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<img
key={p.id}
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className="w-full h-52 object-cover cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
{photos.length > 3 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
<Image size={13} /> +{photos.length - 3}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Content */}
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
)}
{/* Location + time badges */}
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
<MapPin size={11} className="flex-shrink-0" />
{formatLocationName(entry.location_name)}
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
)}
{entry.entry_time && photos.length === 1 && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{prosArr.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
<ThumbsUp size={10} /> Pros
</div>
{prosArr.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{consArr.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
<ThumbsDown size={10} /> Cons
</div>
{consArr.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
{/* Mood + weather */}
{(mood || weather) && (
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
{mood && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
<mood.icon size={11} /> {mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={11} /> {weather.label}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
// Shared gallery renderer
const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map((photo, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
)
// Shared view tab bar
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}> <div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
{/* Cover image background */}
{journey.cover_image && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)} )}
{/* Decorative circles */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} /> <div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} /> <div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
@@ -194,160 +506,98 @@ export default function JourneyPublicPage() {
</div> </div>
{/* Content */} {/* Content */}
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6"> {desktopTwoColumn ? (
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
{/* View tabs */} <div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{availableViews.length > 1 && ( {/* Left: feed */}
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit"> <div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
{availableViews.map(v => ( {renderTabs(availableViews)}
<button {view === 'timeline' && perms.share_timeline && renderTimeline()}
key={v.id} {view === 'gallery' && perms.share_gallery && renderGallery()}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div> </div>
)}
{/* Mobile combined map+timeline (public, read-only) */} {/* Right: sticky map — matches auth page aside proportions */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( <aside
<MobileMapTimeline className="flex-shrink-0"
entries={timelineEntries} style={{
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} width: '44%', minWidth: 420, maxWidth: 760,
dark={document.documentElement.classList.contains('dark')} position: 'sticky', top: 0, height: '100dvh',
readOnly padding: '16px 16px 16px 0',
onEntryClick={() => {}} alignSelf: 'flex-start',
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} }}
/> >
)} <div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
/>
</div>
</aside>
</div>
) : (
// ── Single-column layout (mobile + desktop-without-map) ───────────────
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* Timeline (desktop, or mobile without map permission) */} {/* Floating view toggle — visible above the fullscreen map on mobile */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && ( {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
<div className="flex flex-col gap-6"> <div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
{sortedDates.map(date => { <div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
const dayEntries = groupedEntries.get(date)! {availableViews.map(v => (
const fd = formatDate(date) <button
return ( key={v.id}
<div key={date}> onClick={() => setView(v.id)}
<div className="flex items-center gap-3 mb-4"> className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div> view === v.id
<div> ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div> : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div> }`}
</div> >
</div> <v.icon size={13} />
<div className="flex flex-col gap-4 pl-[52px]"> {v.label}
{dayEntries.map(entry => ( </button>
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden"> ))}
{entry.photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(entry.photos[0], token!)}
className="w-full h-52 object-cover cursor-pointer"
alt=""
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
/>
{entry.photos.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} /> +{entry.photos.length - 1}
</div>
)}
{entry.title && (
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
)}
<div className="px-5 py-4">
{!entry.photos.length && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
)}
{entry.location_name && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
<MapPin size={11} /> {entry.location_name}
</div>
)}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-3 mt-4">
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
{entry.pros_cons.pros!.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
{entry.pros_cons.cons!.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div> </div>
))} </div>
</div> )}
)}
{/* Map */} {renderTabs(availableViews)}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700"> {/* Mobile combined map+timeline (public, read-only) */}
<JourneyMap {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
checkins={[]} <MobileMapTimeline
entries={mapEntries.map(e => ({ entries={timelineEntries}
id: String(e.id), mapEntries={sidebarMapItems as any}
lat: e.location_lat!, dark={document.documentElement.classList.contains('dark')}
lng: e.location_lng!, readOnly
title: e.title || '', onEntryClick={(entry) => setViewingEntry(entry as any)}
mood: e.mood, publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
created_at: e.entry_date, carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
entry_date: e.entry_date,
})) as any}
height={500}
/> />
</div> )}
)}
</div> {/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && renderGallery()}
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={sidebarMapItems as any}
height={500}
/>
</div>
)}
</div>
)}
{/* Powered by */} {/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2"> <div className="flex flex-col items-center py-8 gap-2">
@@ -368,6 +618,26 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
/> />
)} )}
{/* Mobile entry detail view (public share) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={(photos, idx) => setLightbox({
photos: photos.map(p => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
})),
index: idx,
})}
/>
)}
</div> </div>
) )
} }
+1
View File
@@ -642,6 +642,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null }) const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated')) toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false) setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') { if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
} }
+65
View File
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
height?: number | null height?: number | null
} }
export interface GalleryPhoto {
id: number
journey_id: number
photo_id: number
caption?: string | null
shared: number
sort_order: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
width?: number | null
height?: number | null
}
export interface JourneyTrip { export interface JourneyTrip {
trip_id: number trip_id: number
added_at: number added_at: number
@@ -79,6 +97,7 @@ export interface JourneyContributor {
export interface JourneyDetail extends Journey { export interface JourneyDetail extends Journey {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number } stats: { entries: number; photos: number; places: number }
@@ -103,6 +122,9 @@ interface JourneyState {
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void> reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
deletePhoto: (photoId: number) => Promise<void> deletePhoto: (photoId: number) => Promise<void>
clear: () => void clear: () => void
@@ -228,12 +250,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
entries: s.current.entries.map(e => entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
), ),
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
}, },
} }
}) })
return photos return photos
}, },
uploadGalleryPhotos: async (journeyId, formData) => {
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
})
return photos
},
unlinkPhoto: async (entryId, journeyPhotoId) => {
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
),
},
}
})
},
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
})),
},
}
})
},
deletePhoto: async (photoId) => { deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId) await journeyApi.deletePhoto(photoId)
set(s => { set(s => {
+2
View File
@@ -171,6 +171,8 @@ export interface Reservation {
place_id?: number | null place_id?: number | null
assignment_id?: number | null assignment_id?: number | null
accommodation_id?: number | null accommodation_id?: number | null
accommodation_start_day_id?: number | null
accommodation_end_day_id?: number | null
day_plan_position?: number | null day_plan_position?: number | null
metadata?: Record<string, string> | string | null metadata?: Record<string, string> | string | null
needs_review?: number needs_review?: number
+33
View File
@@ -1,5 +1,38 @@
import type { AssignmentsMap } from '../types' import type { AssignmentsMap } from '../types'
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
// Clean short names (≤3 parts) pass through untouched.
export function formatLocationName(raw: string | null | undefined): string {
if (!raw) return ''
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
if (parts.length <= 3) return raw.trim()
// Dedup preserving insertion order
const seen = new Set<string>()
const unique: string[] = []
for (const p of parts) {
if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
}
if (unique.length <= 3) return unique.join(', ')
const name = unique[0]
const last = unique[unique.length - 1]
const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null
// Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
const postcode = isLastPostal ? last : null
const country = isLastPostal ? secondLast : last
const result: string[] = [name]
if (postcode && postcode !== name) result.push(postcode)
if (country && country !== name && country !== postcode) result.push(country)
return result.join(', ')
}
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF']) const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number { export function currencyDecimals(currency: string): number {
+7 -5
View File
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver; globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
// ResizeObserver — used by resizable panels // ResizeObserver — used by resizable panels
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ class _MockResizeObserver {
observe: vi.fn(), observe = vi.fn()
unobserve: vi.fn(), unobserve = vi.fn()
disconnect: vi.fn(), disconnect = vi.fn()
})) as unknown as typeof ResizeObserver; constructor(_callback: ResizeObserverCallback) {}
}
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires // URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE. // a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+577 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.3",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
@@ -25,6 +25,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
@@ -132,6 +133,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -560,6 +571,519 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5083,6 +5607,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5766,6 +6334,13 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.3",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
@@ -30,6 +30,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
+4 -2
View File
@@ -372,8 +372,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
const status = err.statusCode || 500; const status = err.statusCode || err.status || 500;
res.status(status).json({ error: 'Internal server error' }); // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
}); });
return app; return app;
+161
View File
@@ -1946,6 +1946,167 @@ function runMigrations(db: Database.Database): void {
) )
`); `);
}, },
// Migration 121: Journey gallery refactor — decouple photo ownership from
// entries. journey_photos becomes a per-journey gallery (one row per unique
// photo per journey). A new junction table journey_entry_photos links
// gallery photos to the entries that reference them, allowing the same
// photo to appear in multiple entries without duplication. Synthetic
// wrapper entries ('Gallery', '[Trip Photos]') created by the old model
// are removed — the gallery table replaces them.
() => {
const hasOld = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'"
).get();
const hasBackup = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'"
).get();
if (hasOld && !hasBackup) {
db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old');
}
db.exec(`
CREATE TABLE IF NOT EXISTS journey_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
caption TEXT,
shared INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
provider TEXT,
asset_id TEXT,
owner_id INTEGER,
created_at INTEGER NOT NULL,
UNIQUE(journey_id, photo_id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS journey_entry_photos (
entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE,
journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
PRIMARY KEY(entry_id, journey_photo_id)
)
`);
if (hasOld || hasBackup) {
// Backfill gallery: deduplicate by (journey_id, photo_id), keeping
// the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT).
db.exec(`
INSERT OR IGNORE INTO journey_photos
(journey_id, photo_id, caption, shared, sort_order, created_at)
SELECT
je.journey_id,
jpo.photo_id,
jpo.caption,
jpo.shared,
jpo.sort_order,
jpo.created_at
FROM journey_photos_old jpo
JOIN journey_entries je ON je.id = jpo.entry_id
WHERE jpo.id IN (
SELECT MIN(jpo2.id)
FROM journey_photos_old jpo2
JOIN journey_entries je2 ON je2.id = jpo2.entry_id
GROUP BY je2.journey_id, jpo2.photo_id
)
`);
// Backfill junction: one row per (entry_id, photo_id), resolved to
// the new gallery ids.
db.exec(`
INSERT OR IGNORE INTO journey_entry_photos
(entry_id, journey_photo_id, sort_order, created_at)
SELECT
jpo.entry_id,
jp.id,
jpo.sort_order,
jpo.created_at
FROM journey_photos_old jpo
JOIN journey_entries je ON je.id = jpo.entry_id
JOIN journey_photos jp
ON jp.journey_id = je.journey_id
AND jp.photo_id = jpo.photo_id
`);
db.exec('DROP TABLE journey_photos_old');
}
// Remove synthetic wrapper entries replaced by the gallery model.
// ON DELETE CASCADE on journey_entry_photos cleans up junction rows.
db.prepare(
"DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')"
).run();
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
},
// Migration 122: Correct stale day_id / end_day_id on non-transport
// reservations. Migration 110 only backfilled transport types; tours,
// restaurants, events and "other" bookings kept a stale day_id from
// older code paths that often defaulted to the first day of the trip.
// Starting with v3.0.0 the planner renders reservations by day_id
// instead of reservation_time, so those stale rows show up on the
// wrong day. This migration nulls out day_id / end_day_id values that
// don't match the reservation's time and then backfills them from
// reservation_time / reservation_end_time.
() => {
db.exec(`
UPDATE reservations
SET day_id = NULL
WHERE reservation_time IS NOT NULL
AND day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.day_id
AND d.date = substr(reservations.reservation_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET end_day_id = NULL
WHERE reservation_end_time IS NOT NULL
AND end_day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.end_day_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_time IS NOT NULL
AND day_id IS NULL
`);
db.exec(`
UPDATE reservations
SET end_day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_end_time IS NOT NULL
AND end_day_id IS NULL
AND substr(reservations.reservation_end_time, 1, 10)
!= substr(reservations.reservation_time, 1, 10)
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+83 -24
View File
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database'; import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService'; import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router(); const router = express.Router();
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
}, },
}); });
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
}); });
// ── Static prefix routes (MUST come before /:id) ───────────────────────── // ── Static prefix routes (MUST come before /:id) ─────────────────────────
@@ -104,10 +122,11 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
try { try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname); const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) { if (immichId) {
// photo.id is now the gallery photo id (journey_photos.id)
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id); svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
photo.provider = 'immich' as any; (photo as any).provider = 'immich';
photo.asset_id = immichId; (photo as any).asset_id = immichId;
photo.owner_id = authReq.user.id; (photo as any).owner_id = authReq.user.id;
} }
} catch {} } catch {}
} }
@@ -141,16 +160,25 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
res.status(201).json(photo); res.status(201).json(photo);
}); });
// Link an existing photo to a (different) entry // Link a gallery photo to an entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => { router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { photo_id } = req.body || {}; // Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
if (!photo_id) return res.status(400).json({ error: 'photo_id required' }); const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id;
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id); if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' }); if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result); res.status(201).json(result);
}); });
// Unlink a photo from a specific entry (gallery row is preserved)
router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id);
if (!ok) return res.status(404).json({ error: 'Not found or not allowed' });
res.status(204).end();
});
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => { router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {}); const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
@@ -158,34 +186,65 @@ router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) =>
res.json(result); res.json(result);
}); });
// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => { router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id); const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' }); if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) { if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path); const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {} try { fs.unlinkSync(fullPath); } catch {}
} }
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
// photos imported from Immich (no file_path) are just references — don't touch Immich
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
try {
const { getImmichCredentials } = await import('../services/memories/immichService');
const creds = getImmichCredentials(authReq.user.id);
if (creds) {
const { safeFetch } = await import('../utils/ssrfGuard');
await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'DELETE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [photo.asset_id] }),
});
}
} catch {}
}
res.json({ success: true }); res.json({ success: true });
}); });
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
// Upload photos directly to the journey gallery (no entry association)
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos });
});
// Add provider photos to gallery only (no entry link)
router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
// Hard-delete a gallery photo (removes from all entries)
router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
res.status(204).end();
});
// ── Journeys /:id (parameterized routes AFTER static prefixes) ─────────── // ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => { router.get('/:id', authenticate, (req: Request, res: Response) => {
+151 -149
View File
@@ -7,12 +7,22 @@ function ts(): number {
return Date.now(); return Date.now();
} }
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface // Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos.
// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete.
const JP_SELECT = ` const JP_SELECT = `
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`; `;
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id'; const JP_JOIN = `journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tp ON tp.id = gp.photo_id`;
// Per-journey gallery view: journey_photos → trek_photos (no entry context).
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) { function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
const contributors = db.prepare( const contributors = db.prepare(
@@ -58,7 +68,7 @@ export function listJourneys(userId: number) {
return db.prepare(` return db.prepare(`
SELECT DISTINCT j.*, SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count, (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -114,7 +124,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
// group photos by entry // group photos by entry
@@ -123,12 +133,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(
`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`
).all(journeyId);
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -160,7 +169,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats // stats
const entryCount = entries.filter(e => e.type === 'entry').length; const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length; const photoCount = (gallery as any[]).length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare( const userPrefs = db.prepare(
@@ -183,6 +192,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
return { return {
...journey, ...journey,
entries: enrichedEntries, entries: enrichedEntries,
gallery,
trips, trips,
contributors, contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length }, stats: { entries: entryCount, photos: photoCount, places: places.length },
@@ -315,46 +325,22 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
} }
} }
// import trip_photos into journey when a trip is linked // import trip_photos into journey gallery when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) { function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare( const tripPhotos = db.prepare(
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?' 'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; user_id: number; shared: number }[]; ).all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return; if (!tripPhotos.length) return;
const now = ts(); const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
// find or create a "Photos" entry for this trip's photos
let photoEntry = db.prepare(`
SELECT id FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates (by photo_id)
for (const tp of tripPhotos) { for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.photo_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(` db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); `).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
} }
} }
@@ -444,7 +430,7 @@ export function onPlaceDeleted(placeId: number) {
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'skeleton') { if (entry.type === 'skeleton') {
// no content: just delete // no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id); const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) { if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id); db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue; continue;
@@ -469,7 +455,7 @@ export function listEntries(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {}; const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -628,9 +614,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
if (!entry) return false; if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false; if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting // Revert filled entry back to skeleton instead of deleting
db.prepare(` db.prepare(`
@@ -645,12 +628,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
} }
// clean up any empty Gallery entries in this journey
db.prepare(`
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
return true; return true;
} }
@@ -664,23 +641,40 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id); db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
} }
// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now);
const row = db.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekPhotoId) as { id: number };
return row.id;
}
// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, ?, ?)
`).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`)
.get(entryId, galleryId) as JourneyPhoto | null;
}
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null { export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath); const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const now = ts(); const result = linkGalleryPhotoToEntry(galleryId, entryId);
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
} }
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null { export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
@@ -690,119 +684,127 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase); const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added // skip if this photo is already linked to this entry
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId); const alreadyLinked = db.prepare(`
if (exists) return null; SELECT 1 FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; WHERE jep.entry_id = ? AND gp.photo_id = ?
const now = ts(); `).get(entryId, trekPhotoId);
if (alreadyLinked) return null;
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = linkGalleryPhotoToEntry(galleryId, entryId);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
} }
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null { // Link a gallery photo (by its journey_photos.id) to an entry — idempotent.
export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined; // Verify the gallery photo belongs to this journey
if (!source) return null; const galleryRow = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number } | undefined;
if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null;
if (source.entry_id === entryId) return source;
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
const sourceIsGallery = oldEntry?.title === 'Gallery';
// skip if target already has this photo (by trek_photo_id)
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
let resultId: number;
if (sourceIsGallery) {
// Copy so the photo stays in the gallery even after being used in an entry.
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
resultId = Number(res.lastInsertRowid);
} else {
// Non-gallery source: keep existing move behavior.
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
resultId = photoId;
}
const result = linkGalleryPhotoToEntry(galleryRow.id, entryId);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
}
// If we moved out of a Gallery entry (shouldn't happen with the guard above, // Upload photos to the journey gallery only (no entry association).
// but kept for any legacy data), clean up the Gallery wrapper if emptied. export function uploadGalleryPhotos(journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string }[]): JourneyPhoto[] {
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') { if (!canEdit(journeyId, userId)) return [];
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number }; const results: any[] = [];
if (remaining.c === 0) { const now = ts();
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id); const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
} let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const f of filePaths) {
const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail);
db.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, 0, ?, ?)
`).run(journeyId, trekPhotoId, nextOrder++, now);
const row = db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`).get(journeyId, trekPhotoId);
if (row) results.push(row);
} }
return results;
}
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto; // Add a provider photo to the gallery only (no entry link).
export function addProviderPhotoToGallery(journeyId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): any | null {
if (!canEdit(journeyId, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))();
return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
}
// Unlink a photo from a specific entry; gallery row is preserved.
export function unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
const result = db.prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?').run(entryId, journeyPhotoId);
return result.changes > 0;
}
// Hard-delete a gallery photo (removes from all entries and the gallery).
export function deleteGalleryPhoto(journeyPhotoId: number, userId: number): { photo_id: number; file_path?: string | null } | null {
const row = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
deleteTrekPhotoIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null };
} }
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
// Get the trek_photo_id from the journey_photo, then update the central registry // photoId = journey_photos.id (gallery row); look up the trek_photo_id
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined; const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
if (!jp) return; if (!jp) return;
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId); setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
// also denorm on gallery row for fast reads
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
} }
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null { export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(` // photoId = journey_photos.id (gallery row)
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number } | undefined;
JOIN journey_entries je ON jp.entry_id = je.id if (!row) return null;
WHERE jp.id = ? if (!canEdit(row.journey_id, userId)) return null;
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
const fields: string[] = []; // caption lives on the gallery row; sort_order lives on the junction table
const values: unknown[] = []; // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); } // would not be reflected in the returned row).
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); } if (data.caption !== undefined) {
if (!fields.length) return photo; db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
}
values.push(photoId); if (data.sort_order !== undefined) {
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; }
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
} }
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null { // deletePhoto: hard-delete (backwards compat name used by old route).
const photo = db.prepare(` export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
JOIN journey_entries je ON jp.entry_id = je.id if (!row) return null;
WHERE jp.id = ? if (!canEdit(row.journey_id, userId)) return null;
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null; const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(photo.photo_id); deleteTrekPhotoIfOrphan(row.photo_id);
// clean up empty Gallery entries left behind return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
if (!remaining) {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
if (entry && entry.title === 'Gallery' && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
}
}
return photo;
} }
// ── Contributors ───────────────────────────────────────────────────────── // ── Contributors ─────────────────────────────────────────────────────────
+24 -21
View File
@@ -66,11 +66,10 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT jp.photo_id, tkp.owner_id, je.journey_id SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE gp.photo_id = ? AND gp.journey_id = ?
WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any; `).get(photoId, row.journey_id) as any;
if (!photo) return null; if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -81,10 +80,9 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT tkp.owner_id FROM journey_photos jp SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE tkp.asset_id = ? AND gp.journey_id = ?
WHERE tkp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any; `).get(assetId, row.journey_id) as any;
if (!photo) { if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -108,13 +106,13 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photos = db.prepare(` const photos = db.prepare(`
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos jp FROM journey_entry_photos jep
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN journey_entries je ON jp.entry_id = je.id JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE je.journey_id = ? WHERE gp.journey_id = ?
ORDER BY jp.sort_order ORDER BY jep.sort_order
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {}; const photosByEntry: Record<number, any[]> = {};
@@ -122,12 +120,16 @@ export function getPublicJourney(token: string) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY gp.sort_order
`).all(row.journey_id) as any[];
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -138,7 +140,7 @@ export function getPublicJourney(token: string) {
// Stats // Stats
const stats = { const stats = {
entries: entries.length, entries: entries.length,
photos: photos.length, photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size, places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
}; };
@@ -150,6 +152,7 @@ export function getPublicJourney(token: string) {
status: journey.status, status: journey.status,
}, },
entries: enrichedEntries, entries: enrichedEntries,
gallery,
stats, stats,
permissions: { permissions: {
share_timeline: !!row.share_timeline, share_timeline: !!row.share_timeline,
+8 -10
View File
@@ -129,15 +129,14 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
// Journey photos use tripId=0 — check journey_photos + journey_contributors // Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') { if (tripId === '0') {
const journeyPhoto = db.prepare(` const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id SELECT gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id
WHERE tkp.asset_id = ? WHERE tkp.asset_id = ?
AND tkp.provider = ? AND tkp.provider = ?
AND tkp.owner_id = ? AND tkp.owner_id = ?
LIMIT 1 LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; `).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
if (!journeyPhoto) return false; if (!journeyPhoto) return false;
const access = db.prepare(` const access = db.prepare(`
@@ -194,13 +193,12 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
// Check journey_photos — is this photo in a journey the user can access? // Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(` const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos jp SELECT 1 FROM journey_photos gp
JOIN journey_entries je ON je.id = jp.entry_id WHERE gp.photo_id = ?
WHERE jp.photo_id = ?
AND EXISTS ( AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ? SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ? SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
) )
LIMIT 1 LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId); `).get(trekPhotoId, requestingUserId, requestingUserId);
@@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService'; import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache'; import * as photoCache from './trekPhotoCache';
import { ensureLocalThumbnail } from './thumbnailService';
// ── Lookup / Register ──────────────────────────────────────────────────── // ── Lookup / Register ────────────────────────────────────────────────────
@@ -101,7 +102,31 @@ export async function streamPhoto(
} }
if (photo.file_path) { if (photo.file_path) {
const localPath = path.join(__dirname, '../../../uploads', photo.file_path); const uploadsRoot = path.join(__dirname, '../../../uploads');
if (kind === 'thumbnail') {
let thumbRel = photo.thumbnail_path ?? null;
if (!thumbRel) {
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
db.prepare(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
).run(thumbRel, result.width, result.height, photo.id);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.sendFile(thumbAbs);
return;
}
}
// Fall through to original if thumbnail unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) { if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath); res.sendFile(localPath);
@@ -627,7 +627,9 @@ export async function fetchSynologyThumbnailBytes(
mode: 'download', mode: 'download',
id: parsedId.id, id: parsedId.id,
type: 'unit', type: 'unit',
size: 'sm', // Match the uncached streamSynologyAsset default — 'sm' (240px) looked
// pixelated on retina.
size: 'm',
cache_key: parsedId.cacheKey, cache_key: parsedId.cacheKey,
_sid: sid.data, _sid: sid.data,
}); });
@@ -0,0 +1,43 @@
import sharp from 'sharp'
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
export async function ensureLocalThumbnail(
uploadsRoot: string,
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
const originalAbs = path.join(uploadsRoot, originalRelPath)
try { await fs.access(originalAbs) } catch { return null }
// Deterministic name so concurrent requests don't race on the same photo.
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
const thumbRel = `journey/thumbs/${hash}.webp`
const thumbAbs = path.join(uploadsRoot, thumbRel)
try {
const [srcStat, dstStat] = await Promise.all([
fs.stat(originalAbs),
fs.stat(thumbAbs).catch(() => null),
])
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
}
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs)
.rotate()
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
.webp({ quality: THUMB_QUALITY })
.toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
+8 -2
View File
@@ -143,7 +143,7 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
// Validate that the discovery doc's issuer matches the operator-configured // Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer // one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value. // that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) { if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
} }
doc._issuer = url; doc._issuer = url;
@@ -313,7 +313,6 @@ export async function verifyIdToken(
try { try {
const verified = jwt.verify(idToken, publicKey, { const verified = jwt.verify(idToken, publicKey, {
algorithms: [alg as jwt.Algorithm], algorithms: [alg as jwt.Algorithm],
issuer: expectedIssuer,
audience: clientId, audience: clientId,
}); });
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>); claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
@@ -322,6 +321,13 @@ export async function verifyIdToken(
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` }; return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
} }
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
// include a trailing slash in the id_token iss claim.
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
if (tokenIssuer !== expectedIssuer) {
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
}
return { ok: true, claims }; return { ok: true, claims };
} }
+73 -16
View File
@@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
).all(reservationId) as ReservationEndpoint[]; ).all(reservationId) as ReservationEndpoint[];
} }
// Resolve the day row whose date matches the date portion of an ISO-ish
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
// `reservation_time` / `reservation_end_time` so non-transport bookings
// (tours, restaurants, events, ...) end up on the right day in the UI,
// which now filters by day_id instead of reservation_time.
function resolveDayIdFromTime(
tripId: string | number,
time: string | null | undefined,
): number | null {
if (!time) return null;
const datePart = time.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
const row = db
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return row?.id ?? null;
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(` const insert = db.prepare(`
@@ -57,7 +75,8 @@ const saveEndpoints = db.transaction((reservationId: number, endpoints: Endpoint
export function listReservations(tripId: string | number) { export function listReservations(tripId: string | number) {
const reservations = db.prepare(` const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
@@ -93,7 +112,8 @@ export function listReservations(tripId: string | number) {
export function getReservationWithJoins(id: string | number) { export function getReservationWithJoins(id: string | number) {
const row = db.prepare(` const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
@@ -158,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati
} }
} }
// Derive day_id / end_day_id from reservation_time when the client
// didn't explicitly set them (non-hotel bookings only — hotels store
// their date range on the linked day_accommodation).
const resolvedType = type || 'other';
let resolvedDayId: number | null = day_id ?? null;
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
}
let resolvedEndDayId: number | null = end_day_id ?? null;
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review) INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
day_id || null, resolvedDayId,
end_day_id ?? null, resolvedEndDayId,
place_id || null, place_id || null,
assignment_id || null, assignment_id || null,
title, title,
@@ -174,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null, confirmation_number || null,
notes || null, notes || null,
status || 'pending', status || 'pending',
type || 'other', resolvedType,
resolvedAccommodationId, resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null, metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0 needs_review ? 1 : 0
@@ -276,13 +309,8 @@ export function updateReservation(id: string | number, tripId: string | number,
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) { if (start_day_id && end_day_id) {
if (resolvedAccId) { if (resolvedAccId) {
if (accPlaceId) { db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') .run(accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
}
} else if (accPlaceId) { } else if (accPlaceId) {
const accResult = db.prepare( const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
@@ -293,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number,
} }
} }
const resolvedType = (type ?? current.type) || 'other';
const nextReservationTime = resolvedType === 'hotel'
? null
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
const nextReservationEndTime = resolvedType === 'hotel'
? null
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
// day_id / end_day_id: honour an explicit value from the client,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id !== undefined) {
nextDayId = day_id || null;
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
} else {
nextDayId = current.day_id ?? null;
}
let nextEndDayId: number | null;
if (end_day_id !== undefined) {
nextEndDayId = end_day_id ?? null;
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
} else {
nextEndDayId = (current as any).end_day_id ?? null;
}
db.prepare(` db.prepare(`
UPDATE reservations SET UPDATE reservations SET
title = COALESCE(?, title), title = COALESCE(?, title),
@@ -313,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ? WHERE id = ?
`).run( `).run(
title || null, title || null,
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time), nextReservationTime,
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time), nextReservationEndTime,
location !== undefined ? (location || null) : current.location, location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes, notes !== undefined ? (notes || null) : current.notes,
day_id !== undefined ? (day_id || null) : current.day_id, nextDayId,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null, nextEndDayId,
place_id !== undefined ? (place_id || null) : current.place_id, place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null, status || null,
+18
View File
@@ -681,6 +681,24 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
} }
const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertTodo = db.prepare(`
INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority)
VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)
`);
for (const t of oldTodos) {
insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority);
}
const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[];
const insertCategoryOrder = db.prepare(`
INSERT INTO budget_category_order (trip_id, category, sort_order)
VALUES (?, ?, ?)
`);
for (const o of oldCategoryOrder) {
insertCategoryOrder.run(newTripId, o.category, o.sort_order);
}
return Number(newTripId); return Number(newTripId);
}); });
+18
View File
@@ -389,6 +389,24 @@ export interface JourneyPhoto {
height?: number | null; height?: number | null;
} }
export interface GalleryPhoto {
id: number;
journey_id: number;
photo_id: number;
caption?: string | null;
shared: number;
sort_order: number;
created_at: number;
// Joined from trek_photos for API responses
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
}
export interface JourneyTrip { export interface JourneyTrip {
journey_id: number; journey_id: number;
trip_id: number; trip_id: number;
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({}); .send({});
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe('photo_id required'); expect(res.body.error).toBe('journey_photo_id required');
}); });
}); });
@@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => {
it('returns empty array for non-first-login user with no applicable notices', async () => { it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice // login_count > 1 means firstLogin condition does not match for any notice;
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
@@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => {
SYSTEM_NOTICES.push(TEST_NOTICE); SYSTEM_NOTICES.push(TEST_NOTICE);
try { try {
const { user } = createUser(testDb); const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
+46
View File
@@ -950,6 +950,52 @@ describe('Copy trip with data', () => {
expect(newNotes).toHaveLength(1); expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!'); expect(newNotes[0].text).toBe('Pack early!');
}); });
it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
// Two budget category order rows
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
insOrder.run(trip.id, 'Transport', 0);
insOrder.run(trip.id, 'Accommodation', 1);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Todo Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
// Todos copied with checked reset and assigned_user_id nulled
const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newTodos).toHaveLength(2);
expect(newTodos[0].name).toBe('Buy tickets');
expect(newTodos[0].category).toBe('Transport');
expect(newTodos[0].checked).toBe(0);
expect(newTodos[0].assigned_user_id).toBeNull();
expect(newTodos[0].due_date).toBe('2026-06-01');
expect(newTodos[0].description).toBe('Check Ryanair');
expect(newTodos[0].priority).toBe(1);
expect(newTodos[1].name).toBe('Book hotel');
expect(newTodos[1].checked).toBe(0);
expect(newTodos[1].assigned_user_id).toBeNull();
// Budget category order copied
const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newOrder).toHaveLength(2);
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1325,9 +1325,10 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id); const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true); expect(result).toBe(true);
// Photo should be deleted with the entry // Junction row must be gone (ON DELETE CASCADE from journey_entries).
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any; // Gallery row (journey_photos) is preserved — photo may belong to other entries.
expect(deletedPhoto).toBeUndefined(); const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
}); });
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1395,17 +1396,12 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id); addTripToJourney(journey.id, trip.id, user.id);
// Should have a [Trip Photos] entry with the imported photo // Trip photos now go straight into the journey gallery (no wrapper entry).
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(` const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ? WHERE jp.journey_id = ?
`).all(photoEntry.id); `).all(journey.id);
expect(photos.length).toBe(1); expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1'); expect((photos[0] as any).asset_id).toBe('immich-photo-1');
}); });
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */ /** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto( function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,10 +70,24 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number; const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(` testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?) VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now()); `).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response // Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId; return trekId;
@@ -4,6 +4,8 @@
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex. * discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
// ── DB setup ────────────────────────────────────────────────────────────────── // ── DB setup ──────────────────────────────────────────────────────────────────
@@ -50,6 +52,7 @@ import {
frontendUrl, frontendUrl,
findOrCreateUser, findOrCreateUser,
discover, discover,
verifyIdToken,
} from '../../../src/services/oidcService'; } from '../../../src/services/oidcService';
const MOCK_CONFIG = { const MOCK_CONFIG = {
@@ -460,3 +463,66 @@ describe('getUserInfo', () => {
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123'); expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
}); });
}); });
// ── verifyIdToken ─────────────────────────────────────────────────────────────
describe('verifyIdToken', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const ISSUER = 'https://auth.example.com/application/o/trek';
const CLIENT_ID = 'trek-client';
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
function mockJwks() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}));
}
function makeToken(iss: string, overrides: object = {}) {
return jwtLib.sign(
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
privateKey,
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
);
}
const doc = { jwks_uri: JWKS_URI } as any;
afterEach(() => { vi.unstubAllGlobals(); });
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
mockJwks();
const token = makeToken(ISSUER);
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
mockJwks();
const token = makeToken(ISSUER + '/');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
mockJwks();
const token = makeToken('https://evil.example.com');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
expect((result as any).error).toMatch('jwt issuer invalid');
});
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
mockJwks();
const token = makeToken(ISSUER, {});
const wrongAudToken = jwtLib.sign(
{ sub: 'user-sub', iss: ISSUER },
privateKey,
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
);
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
});
});
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional: Some addons require credentials or environment variables before they are functional:
- **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers). - **Journey**works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly. - **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages ## Related pages
+11 -30
View File
@@ -36,10 +36,7 @@ feat(component): short description of new feature
### PR Description ### PR Description
Include: Follow the template provided by default (.github/PULL_REQUEST_TEMPLATE.md).
1. **Summary** — What does this PR do? (1-3 bullet points)
2. **Test plan** — How was this tested?
3. **Related issue** — Link the issue (e.g. `Fixes #123`)
### What Will Get Your PR Closed ### What Will Get Your PR Closed
@@ -51,32 +48,16 @@ Include:
## Development Setup ## Development Setup
```bash See the [[Development Environment|Development-environment]] page for the full setup guide, including forking, remote configuration, branch conventions, and available scripts.
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 runs on `http://localhost:3001`
- Client runs on `http://localhost:5173` (with proxy to server)
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
|---|---| |---|---------------------------------------------------------------------------------|
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite | | Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
| Backend | Express, TypeScript, better-sqlite3 | | Backend | Express, TypeScript, better-sqlite3 |
| Real-time | WebSocket (ws) | | Real-time | WebSocket (ws) |
| Database | SQLite with WAL mode | | Database | SQLite with WAL mode |
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC | | Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles | | Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
| i18n | 13 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, AR) | | i18n | 15 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, ZH-TW, AR, ID) |
+2 -2
View File
@@ -1,6 +1,6 @@
# Developer Setup Guide # Developer Setup Guide
> Before anything else, please read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/blob/main/CONTRIBUTING.md). > Before anything else, please read the [[Contributing]] guidelines.
## Prerequisites ## Prerequisites
@@ -135,4 +135,4 @@ Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev`
- Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work. - Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work.
- Run tests before pushing: `npm run test` in both `client/` and `server/`. - Run tests before pushing: `npm run test` in both `client/` and `server/`.
- Follow the commit message conventions described in the [Contributing Guidelines](https://github.com/mauriceboe/TREK/blob/main/CONTRIBUTING.md). - Follow the commit message conventions described in the [[Contributing]] guidelines.
+7 -7
View File
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy ## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation. These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO ## OIDC / SSO
For setup instructions, see [OIDC-SSO]. For setup instructions, see [OIDC-SSO](OIDC-SSO).
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP ## MCP
For setup instructions, see [MCP-Overview]. For setup instructions, see [MCP-Overview](MCP-Overview).
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview].
## Related Pages ## Related Pages
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio - [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO] — complete OIDC configuration guide - [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
- [MCP-Overview] — MCP server setup and rate limiting - [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data - [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
+15 -7
View File
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip - **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_ ### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking - **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown - **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** — travel journal linking entries to trips, with contributor roles - **Journey** magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
- **Memories** — photo-focused trip memories - **Naver List Import** — import places from shared Naver Maps lists
- **Collab**group chat, shared notes, polls, and activity sign-ups - **MCP**expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
### AI / MCP Integration ### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication - **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more - **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups - **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders - **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin ### Admin
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups - Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements - System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically. > **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
## Get Started ## Get Started
@@ -58,3 +64,5 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip | | [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
| [Admin Panel](Admin-Panel-Overview) | Configure your instance | | [Admin Panel](Admin-Panel-Overview) | Configure your instance |
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client | | [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
| [Contributing](Contributing) | Guidelines for submitting pull requests |
| [Development Environment](Development-environment) | Set up a local dev environment |
+5 -5
View File
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
APP_URL=https://trek.example.com APP_URL=https://trek.example.com
``` ```
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables]. Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
## Start TREK ## Start TREK
@@ -111,10 +111,10 @@ docker compose logs -f
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`. This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
See [Reverse-Proxy] for complete proxy configuration examples. See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples.
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference - [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy] — HTTPS configuration - [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating] — how to pull a new image - [Updating](Updating) — how to pull a new image
+6 -6
View File
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
-e ALLOWED_ORIGINS=https://trek.example.com \ -e ALLOWED_ORIGINS=https://trek.example.com \
``` ```
See [Environment-Variables] for the full list. See [Environment-Variables](Environment-Variables) for the full list.
## Volume Reference ## Volume Reference
@@ -66,11 +66,11 @@ docker logs trek
## Limitations of `docker run` ## Limitations of `docker run`
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
## Next Steps ## Next Steps
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag - [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose] — recommended for production - [Install-Docker-Compose](Install-Docker-Compose) — recommended for production
- [Environment-Variables] — full list of configurable variables - [Environment-Variables](Environment-Variables) — full list of configurable variables
- [Updating] — how to pull a new image without losing data - [Updating](Updating) — how to pull a new image without losing data
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference - [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments - [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps ## Next Steps
- [Environment-Variables] — complete variable reference - [Environment-Variables](Environment-Variables) — complete variable reference
- [Updating] — how to pull a new image on Unraid - [Updating](Updating) — how to pull a new image on Unraid
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser. TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access). > **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
--- ---
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps ## Next Steps
- [Install-Docker-Compose] — production setup with security hardening - [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening
- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies) - [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies)
- [Environment-Variables] — full configuration reference - [Environment-Variables](Environment-Variables) — full configuration reference
- [Admin-Panel-Overview] — explore what the admin panel can do - [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do
+3 -3
View File
@@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group:
If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`. If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
See [Environment-Variables] for full documentation of these and all other variables. See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables.
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference including OIDC - [Environment-Variables](Environment-Variables) — full variable reference including OIDC
- [Install-Docker-Compose] — production compose file with proxy-ready env vars - [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
+20 -1
View File
@@ -1,7 +1,9 @@
# Tags and Categories # Tags and Categories
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users). TREK has two independent labelling systems for places:
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail --> <!-- TODO: screenshot: tag list on place detail -->
@@ -24,6 +26,23 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them. > **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which ## When to use which
| Use case | Use | | Use case | Use |
+151
View File
@@ -1,5 +1,24 @@
# Troubleshooting # Troubleshooting
## "Access token required" when changing password on first login
**Cause:** The session cookie has the `Secure` flag set, which means the browser will only send it over HTTPS. When accessing TREK over plain HTTP (e.g. `http://192.168.1.x:3000`), the browser silently drops the cookie and the server sees no session — returning "Access token required".
**Fix:** Choose one of the following options:
**Option 1 — Use HTTPS.** Access TREK via HTTPS with a valid SSL certificate.
**Option 2 — Disable the Secure flag.** Set `COOKIE_SECURE=false` in your Docker environment to allow the session cookie to be sent over plain HTTP:
```yaml
environment:
- COOKIE_SECURE=false
```
> **Note:** Option 2 is only recommended for internal/home-lab deployments that do not use HTTPS. Do not use it on a publicly accessible instance. See [Environment Variables](Environment-Variables).
---
## WebSocket not connecting / real-time sync broken ## WebSocket not connecting / real-time sync broken
**Cause:** Your reverse proxy is not forwarding WebSocket upgrade headers on the `/ws` path. **Cause:** Your reverse proxy is not forwarding WebSocket upgrade headers on the `/ws` path.
@@ -83,3 +102,135 @@ Add this to the `location /` block (or the specific backup route). See [Reverse
```bash ```bash
sudo chown -R 1000:1000 ./data ./uploads sudo chown -R 1000:1000 ./data ./uploads
``` ```
---
## Encryption key regenerated on restart — stored secrets stop working
**Cause:** On every startup, TREK resolves its encryption key in this order: (1) `ENCRYPTION_KEY` env var, (2) `data/.encryption_key` file, (3) legacy `data/.jwt_secret` fallback, (4) auto-generate a fresh key. If neither the env var nor the `data/` volume is persisted — for example after recreating a container without a volume mount — a new random key is generated and all stored secrets (SMTP password, OIDC client secret, API keys, MFA TOTP seeds) become unrecoverable.
**Fix:** Ensure `./data:/app/data` is mounted as a persistent volume so `data/.encryption_key` survives restarts. Alternatively, pin the key explicitly:
```yaml
environment:
- ENCRYPTION_KEY=<your-key>
```
See [Encryption Key Rotation](Encryption-Key-Rotation) for how to retrieve or rotate the key.
---
## OIDC login returns "APP_URL is not configured"
**Cause:** When OIDC is enabled, TREK needs to know its own public URL to build the redirect URI. It resolves this from (1) `APP_URL` env var, (2) the first entry in `ALLOWED_ORIGINS`, (3) `http://localhost:<PORT>` as a last resort. If none of these are set and the request is not coming from localhost, TREK returns a 500 error.
**Fix:** Set `APP_URL` to the public URL of your instance:
```yaml
environment:
- APP_URL=https://trek.example.com
```
---
## OIDC login fails with issuer mismatch
**Cause:** TREK validates that the `issuer` field in the provider's discovery document exactly matches the configured `OIDC_ISSUER`. A trailing-slash difference (e.g. `https://auth.example.com` vs `https://auth.example.com/`) is enough to fail.
**Fix:** Check the exact issuer value your provider advertises and match it:
```bash
curl -s https://<your-oidc-issuer>/.well-known/openid-configuration | jq .issuer
```
Set `OIDC_ISSUER` to that exact string.
---
## OIDC login fails when provider is on a private/internal network
**Cause:** TREK's SSRF guard blocks outbound requests to private IP ranges by default. If your OIDC provider (e.g. Keycloak, Authentik) is running on an internal address, the discovery document fetch will be blocked with: `Requests to private/internal network addresses are not allowed.`
**Fix:**
```yaml
environment:
- ALLOW_INTERNAL_NETWORK=true
```
---
## Password reset emails are not delivered / SMTP is silent
**Cause:** SMTP failures are logged but do not surface as errors to the end user — the "reset email sent" message appears regardless. Common causes: wrong `SMTP_HOST` or `SMTP_PORT`, bad credentials, firewall blocking outbound on the SMTP port, or a self-signed certificate on the SMTP server.
**Fix:**
1. Check server logs for `Email send failed`:
```bash
docker logs <container> 2>&1 | grep "Email send failed"
```
2. If the error mentions TLS or certificate, set `SMTP_SKIP_TLS_VERIFY=true`.
3. Verify the port: `587` for STARTTLS, `465` for implicit TLS, `25` for plain SMTP.
4. Test connectivity from the container:
```bash
docker exec <container> nc -zv <SMTP_HOST> <SMTP_PORT>
```
> **Note:** If no SMTP is configured at all, TREK prints the reset link directly to the server logs (`===== PASSWORD RESET LINK =====`). This is useful for initial setup or self-hosted installs without email.
---
## CORS error — API requests blocked in the browser
**Cause:** If `ALLOWED_ORIGINS` is set, only those origins are permitted. Any request from a different origin is rejected with a CORS error visible in the browser console.
**Fix:** Add your origin to the comma-separated list:
```yaml
environment:
- ALLOWED_ORIGINS=https://trek.example.com,https://other.example.com
```
If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). See [Environment Variables](Environment-Variables).
---
## WebSocket closes immediately after connecting (codes 4001 / 4403)
**Cause:** The `/ws` endpoint requires an ephemeral token generated by the client immediately before connecting. If the token is missing, expired, or the user's session state changed, the server closes the connection with a specific code:
| Code | Reason |
|------|--------|
| `4001` | No token, expired/invalid token, or user not found — re-login required |
| `4403` | MFA is required globally but the user has not enabled it |
**Fix:**
- Code `4001`: Log out and log back in. If it persists, check that your reverse proxy is not stripping the `token` query parameter from the WebSocket upgrade request.
- Code `4403`: The user must enable MFA in **Settings > Security**, or an admin can disable the global MFA requirement in **Admin > Settings**.
---
## Clipboard features not working (copy link, share, etc.)
**Cause:** The browser Clipboard API (`navigator.clipboard`) is only available in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). When accessing TREK over plain HTTP on a non-localhost address, the API is unavailable and clipboard operations silently fail or show an error.
**Fix:** The only supported options are:
- Access TREK over HTTPS with a valid SSL certificate.
- Access TREK directly from `http://localhost:<port>` — browsers treat `localhost` as a secure context for the Clipboard API (unlike the session cookie, which always requires HTTPS regardless of hostname).
---
## MCP integration: "Too many requests" or "Session limit reached"
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
**Fix:** Increase the limits via environment variables:
```yaml
environment:
- MCP_RATE_LIMIT=600 # requests per minute per user (default: 300)
- MCP_MAX_SESSION_PER_USER=50 # concurrent sessions per user (default: 20)
```
+5 -5
View File
@@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data.
## Before You Update ## Before You Update
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details. Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details.
## Docker Compose (Recommended) ## Docker Compose (Recommended)
@@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi
If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade. If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade.
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure. If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
## Unraid ## Unraid
@@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid
## Next Steps ## Next Steps
- [Backups] — schedule automatic backups so you always have a restore point before updates - [Backups](Backups) — schedule automatic backups so you always have a restore point before updates
- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key - [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key
- [Install-Docker-Compose] — switch to Compose for easier future updates - [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates
+4
View File
@@ -92,3 +92,7 @@
## Help ## Help
- [[FAQ]] - [[FAQ]]
- [[Troubleshooting]] - [[Troubleshooting]]
## Contributing
- [[Contributing]]
- [[Development Environment|Development-environment]]