Compare commits

...

40 Commits

Author SHA1 Message Date
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
66 changed files with 1332 additions and 492 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
+2 -1
View File
@@ -60,4 +60,5 @@ coverage
.scannerwork .scannerwork
test-data test-data
.run .run
.full-review
+5 -1
View File
@@ -6,7 +6,11 @@
<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. <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.
+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 }> = {
@@ -130,7 +131,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>
)} )}
@@ -237,6 +237,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 +422,6 @@ 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 */}
<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()} 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> </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 }
+4 -4
View File
@@ -1468,7 +1468,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 +1493,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);
@@ -2905,7 +2905,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();
}); });
}); });
+114 -56
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'
@@ -67,11 +69,13 @@ 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(),
} }
} }
@@ -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>()
@@ -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
@@ -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
@@ -733,7 +761,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 +845,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 +901,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 +944,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>
@@ -981,9 +1010,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
}, []) }, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
for (const e of entries) { for (const e of entries) {
for (const p of e.photos) { for (const p of e.photos) {
allPhotos.push({ photo: p, entry: e }) const existing = seenPhotoIds.get(p.photo_id)
if (existing === undefined) {
seenPhotoIds.set(p.photo_id, allPhotos.length)
allPhotos.push({ photo: p, entry: e })
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
allPhotos[existing] = { photo: p, entry: e }
}
} }
} }
@@ -1028,23 +1064,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} }
const handleDeletePhoto = async (photoId: number) => { const handleDeletePhoto = async (photoId: 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 = { const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
...store.current, if (!target) return
entries: store.current.entries.map(e => ({ const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
...e,
photos: e.photos.filter(p => p.id !== photoId), // Optimistic update — remove every row with this photo_id
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), const updated = {
} ...store.current,
useJourneyStore.setState({ current: updated }) entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.photo_id !== target.photo_id),
})).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 Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() // Revert on error onRefresh()
} }
} }
@@ -1358,7 +1398,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 +1441,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 +1520,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 +1804,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 +1818,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 +1904,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 +1957,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 +1982,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 +2055,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>
@@ -2214,6 +2254,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_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 +2366,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 +2386,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>
@@ -2952,7 +2995,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 +3003,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 +3016,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 +3078,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 +3179,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 +3230,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 +3277,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>
) )
} }
+11 -25
View File
@@ -234,28 +234,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 +295,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');
}); });
+399 -160
View File
@@ -1,14 +1,22 @@
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 { 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 +44,31 @@ interface PublicPhoto {
caption?: 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: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: PublicPhoto, 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 +94,15 @@ 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 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
@@ -84,10 +117,6 @@ export default function JourneyPublicPage() {
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
// 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( const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries], [entries],
@@ -100,12 +129,43 @@ export default function JourneyPublicPage() {
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// 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(() => {
if (!perms.share_timeline && perms.share_gallery) setView('gallery') if (!perms.share_timeline && perms.share_gallery) setView('gallery')
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 +185,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 && 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">
{/* 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(({ photo: 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' }}>
{/* 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 +495,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 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={() => {}}
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">
+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(() => {})
} }
+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

+2 -2
View File
@@ -58,7 +58,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(DISTINCT jp.photo_id) 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(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
@@ -160,7 +160,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 = new Set(photos.map(p => p.photo_id)).size;
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(
@@ -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,
}); });
+6 -9
View File
@@ -57,7 +57,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 +94,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
@@ -276,13 +278,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 (?, ?, ?, ?, ?, ?, ?)'
+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);
}); });
+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 });
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+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.
+2
View File
@@ -58,3 +58,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 |
+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)
```
+4
View File
@@ -92,3 +92,7 @@
## Help ## Help
- [[FAQ]] - [[FAQ]]
- [[Troubleshooting]] - [[Troubleshooting]]
## Contributing
- [[Contributing]]
- [[Development Environment|Development-environment]]