Compare commits

...

26 Commits

Author SHA1 Message Date
Maurice 10d1f8d428 test(todo): update add-task tests for toolbar button migration
The "Add new task..." button moved from the panel into the shared
toolbar and is triggered via addItemSignal. Rewrite the three affected
tests to drive that signal through a rerender instead of clicking the
removed in-panel button.
2026-04-18 00:25:06 +02:00
Maurice 0c00f8e0b3 feat(about): add monthly supporters section with 5 tiers
- Tier cards (Hostel Bunkmate through No Return Ticket) with gradient
  icons and placeholder state for empty tiers
- Animated shimmer badge and subtle radial glow behind the card
- Mobile-responsive layout, name chips show just the month on small
  screens to avoid overflow
- Copy + translations for all 15 supported languages
2026-04-18 00:22:00 +02:00
Maurice 71637a8483 fix(tests): restore packing panel inline header + update tests for ui changes
- PackingListPanel accepts inlineHeader prop (default true) to keep its
  legacy title and inline import button; ListsContainer passes
  inlineHeader={false} since the toolbar now owns those controls
- ReservationModal tests look for the renamed 'Car' button (was 'Rental Car')
- Budget total-budget test asserts against the split integer/decimal
  spans that replaced the single text node
2026-04-17 23:56:42 +02:00
Maurice 189b257254 Merge remote-tracking branch 'origin/dev' into dev-maurice
# Conflicts:
#	client/src/components/Todo/TodoListPanel.tsx
#	server/src/db/migrations.ts
2026-04-17 23:44:53 +02:00
Julien G. 9a31fcac7b Merge pull request #710 from mauriceboe/feat/photo-thumbnail-cache-686
feat(photos): 1h disk cache for remote thumbnails + fix tab-switch redundant requests
2026-04-17 21:28:42 +02:00
jubnl 677157de1d test(journey): fix getByText assertions broken by keep-mounted tab change
Tabs are now always mounted (visibility toggled via hidden class), so
the same entry title can appear in multiple tab views simultaneously.
Replace getByText with getAllByText for presence checks; scope the
FE-086 click target to the cursor-pointer container.
2026-04-17 21:02:46 +02:00
jubnl b5b1d32b31 feat(photos): add 1h disk cache for remote thumbnails and keep tabs mounted
Closes #686

- Add trekPhotoCache service: SHA1-keyed disk cache under uploads/photos/trek/,
  1h TTL, in-flight dedup map to prevent stampedes on concurrent requests
- Add migration 108: trek_photo_cache_meta table
- Hook cache into streamPhoto for Immich/Synology thumbnail path;
  originals bypass cache
- Add fetchImmichThumbnailBytes / fetchSynologyThumbnailBytes returning
  Buffer instead of piping, used by the cache layer
- Add scheduler entry (every 2h + startup sweep) to evict expired disk
  files and DB rows via sweepExpired()
- Client: convert journey tab conditional-mount to hidden-toggle so
  img elements stay in DOM across tab switches, preventing redundant
  thumbnail requests on rapid tab changes
- Expose invalidateSize() on JourneyMapHandle; call it on map tab
  activation to fix Leaflet rendering in previously-hidden container
2026-04-17 20:49:38 +02:00
jubnl ae4dfc48cc fix(pdf): add allow-scripts to iframe sandbox to suppress CSP warning 2026-04-17 20:22:31 +02:00
Julien G. 3b487519a5 Merge pull request #709 from mauriceboe/feat/system-notice-version-gate
feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
2026-04-17 20:15:19 +02:00
Julien G. 1425c4e05b Update maxVersion explanation in system-notices.md
Clarified the explanation regarding setting maxVersion for notices.
2026-04-17 20:09:34 +02:00
Julien G. a84aedc3b4 Fix range notation for app version filtering 2026-04-17 20:07:34 +02:00
jubnl 4b7ba6cb3f feat(system-notices): apply version gates to v3 upgrade notices 2026-04-17 20:04:54 +02:00
jubnl 5952e02971 feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
Prevents users who upgrade across multiple versions from seeing all
interim notices at once. Version bounds are evaluated server-side using
semver.coerce so prerelease builds compare as their base release.
Range is lower-inclusive, upper-exclusive: maxVersion: '4.0.0' hides
the notice once 4.0.0 ships.
2026-04-17 20:03:23 +02:00
jubnl 8cd5aa0d23 fix(synology): correct multi-album passphrase assignment and stale trek_photos
- ProviderPicker now tracks per-asset album passphrase in a Map; on confirm,
  assets are grouped by passphrase and submitted as separate batches so each
  asset receives its own album's passphrase instead of the last-selected one
- getOrCreateTrekPhoto unconditionally overwrites the stored passphrase when
  a fresh one is supplied, allowing re-adds to heal a stuck bad passphrase
- deleteTrekPhotoIfOrphan purges the trek_photos row for provider assets when
  no trip_photos or journey_photos reference it anymore; wired into
  removeTripPhoto, removeAlbumLink, and deletePhoto so remove + re-add is a
  clean slate
- Three new integration tests: SYNO-090 (passphrase overwrite), SYNO-091
  (orphan cleanup), SYNO-092 (remove + re-add restores correct passphrase)
2026-04-17 19:48:12 +02:00
Julien G. c0aa252f9a Merge pull request #708 from mauriceboe/fix/google-places-api-quota-reduction
fix(maps): reduce Google Places API quota with persistent caching and kill-switch
2026-04-17 19:33:51 +02:00
jubnl 8a58ce51c0 feat(maps): add kill switches for Google Places autocomplete and details
Add admin toggles for places_autocomplete_enabled and places_details_enabled
alongside the existing places_photos_enabled, all default ON.

- adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails
- admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details
- maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off;
  details returns { place: null, disabled: true } when off
- authService: both flags included in getAppConfig() response
- authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters
- App.tsx: wire both flags from app-config on load
- AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI
- i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw)
2026-04-17 19:28:40 +02:00
jubnl 9c2decb095 fix(maps): reduce Google Places API quota usage with persistent caching
P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly

P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64

Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival

Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
2026-04-17 19:07:39 +02:00
Julien G. 39f13881c5 Merge pull request #707 from mauriceboe/fix/journey-page-bugs
fix(journey): fix issue #704 — active logic, archive, places rename, search & trip reminders
2026-04-17 17:05:43 +02:00
jubnl 3b94727c07 fix(journey): fix issue #704 — active logic, archive, places rename, search, trip reminders
- Derive journey lifecycle from linked trip dates (live/upcoming/completed/draft)
  instead of relying solely on status field; status=archived always wins
- Add Archive/Restore Journey action in journey settings dialog
- Rename cities → places end-to-end (SQL alias, TS types, stats field, all locales)
- Wire up search icon: toggles inline input, filters by title+subtitle client-side
- Fix channelConfigured check: trip reminders enabled by default since inapp is
  always available; remove channel check, controlled solely by admin setting
- Expose notify_trip_reminder toggle in Admin → Settings → Notifications
- Add trip_date_min/trip_date_max to listJourneys SQL for client-side lifecycle
- Add archived status to Journey type (server + client)
- Update all 15 locale files with new keys (search, archive, places, trip reminders)
2026-04-17 16:59:23 +02:00
Julien G. 4a5a461d25 Merge pull request #701 from mauriceboe/fix/mobile-overlay-bottom-nav
fix(mobile): account for bottom navbar in overlays and improve system notices UX
2026-04-17 15:40:57 +02:00
jubnl 1963573db4 fix(synology): use Thumbnail API with size xl for originals to avoid HEIC
Replace SYNO.Foto.Download with SYNO.Foto.Thumbnail (size=xl) for the
original kind, mirroring the Immich approach. Synology's download endpoint
returns the raw file (HEIC for iPhone photos), while the Thumbnail API
always serves a browser-compatible JPEG render.
2026-04-17 15:35:42 +02:00
jubnl 5046e1a2e0 fix(synology): wire shared-album passphrase through journey-entry add flow
Thread selectedAlbumPassphrase from ProviderPicker through onAdd →
journeyApi.addProviderPhotos → POST /entries/:entryId/provider-photos →
addProviderPhoto service → getOrCreateTrekPhoto so shared-album photos
have their passphrase encrypted and persisted on trek_photos at add-time,
enabling streamPhoto to forward it to Synology correctly (#689).
2026-04-17 15:33:05 +02:00
jubnl a1f3b4476e fix(system-notices): overhaul mobile bottom sheet UX
- Replace "Next Notice >" CTA with proper < > pager buttons
- Fix shared scroll container: each slot now scrolls independently
- Sheet uses fixed h-[85dvh] so height is consistent across all notices
- Sticky footer (pager + CTA) always anchored at bottom of each slot
- Content area vertically centered when shorter than available space
- Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back)
- Scroll position resets on navigation via per-slot refs
- Adjacent slot scroll cleared on horizontal gesture classification
- OK button navigates to next notice on non-last pages, dismisses on last
- OK button only shown when dismissible or on last notice
2026-04-17 15:06:23 +02:00
jubnl b2a39a3071 Merge dev into fix/mobile-overlay-bottom-nav, resolve conflicts 2026-04-17 00:01:18 +02:00
jubnl e078a9d9e1 fix: getAppVersion now getting 1st from environment, fallback to package.json, fallback to 0.0.0 if all failed 2026-04-16 23:36:33 +02:00
jubnl fef12b0e8b fix(mobile): account for bottom navbar in overlays and improve system notices UX
- Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were
  clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal,
  JourneyPage create modal, TodoListPanel sheets, TripPlannerPage
  PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes,
  FileManager viewer, and shared Modal primitive
- Replace single-notice mobile bottom sheet with a 3-slot horizontal strip
  so adjacent notices are physically present during drag
- Add live-follow swipe left/right to navigate between notices with
  spring-back when under threshold and flushSync to eliminate blink on commit
- Add live-follow swipe down to dismiss all notices with spring-back;
  backdrop tap also triggers the slide-down animation
- Normalize notice height with useLayoutEffect minHeight on strip and
  align-items: stretch so all slots are always the tallest notice height
- Pin CTA button at consistent Y across notices via flex-1 + mt-auto;
  always render invisible Not now placeholder to equalise CTA section height
- Move pager dots/counter below CTA buttons
2026-04-16 22:49:20 +02:00
75 changed files with 2678 additions and 544 deletions
+5 -2
View File
@@ -100,7 +100,7 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
@@ -116,7 +116,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -125,6 +125,9 @@ export default function App() {
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
+8 -2
View File
@@ -272,6 +272,12 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -331,8 +337,8 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight');
await screen.findByText('Hotel');
// Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument();
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
+1 -1
View File
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
+6 -1
View File
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
interface MapEntry {
@@ -151,7 +152,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
}
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
const invalidateSize = useCallback(() => {
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
useEffect(() => {
if (!containerRef.current) return
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
paddingBottom: 'var(--bottom-nav-h)',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
+10 -10
View File
@@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>`
}
// Base64 data URL thumbnails — no external image fetch during zoom
// Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && place.image_url.startsWith('data:')) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
// while the thumb is still being generated in the background
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -277,6 +277,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
@@ -409,20 +410,19 @@ export const MapView = memo(function MapView({
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => {
if (!places || places.length === 0) return
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
const setThumb = (cacheKey: string, thumb: string) => {
iconCache.clear()
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
}
for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue
@@ -435,9 +435,9 @@ export const MapView = memo(function MapView({
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
const photoId = place.google_place_id || place.osm_id
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
const photoId = place.image_url || place.google_place_id || place.osm_id
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
@@ -445,7 +445,7 @@ export const MapView = memo(function MapView({
}
return () => cleanups.forEach(fn => fn())
}, [placeIds])
}, [placeIds, placesPhotosEnabled])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
@@ -462,7 +462,7 @@ export const MapView = memo(function MapView({
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
+1 -1
View File
@@ -308,7 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
+1 -1
View File
@@ -521,7 +521,7 @@ ${daysHtml}
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
@@ -730,9 +730,10 @@ interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
inlineHeader?: boolean
}
export default function PackingListPanel({ tripId, items, openImportSignal = 0 }: PackingListPanelProps) {
export default function PackingListPanel({ tripId, items, openImportSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
@@ -1008,14 +1009,34 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0 }
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '0 0 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14 }}>
{items.length > 0 ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
) : <span />}
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
{inlineHeader ? (
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
{items.length > 0 && (
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
)}
</div>
) : (
items.length > 0 ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
) : <span />
)}
<div style={{ display: 'flex', gap: 6 }}>
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
@@ -1267,7 +1288,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0 }
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{/* Main area */}
@@ -107,7 +107,7 @@ describe('ReservationModal', () => {
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
@@ -636,7 +636,7 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
// Car type still shows date fields (not hotel which hides them)
await waitFor(() => {
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
+225 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import Section from './Section'
@@ -7,8 +7,229 @@ interface Props {
appVersion: string
}
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
interface SupporterTier {
id: SupporterTierId
labelKey: string
price: string
gradient: string
glow: string
icon: typeof Tent
}
const SUPPORTER_TIERS: SupporterTier[] = [
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
]
interface Supporter {
username: string
tier: SupporterTierId
since: string
link?: string
}
const SUPPORTERS: Supporter[] = [
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
]
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
if (SUPPORTERS.length === 0) return null
const formatSince = (yearMonth: string): string => {
const [y, m] = yearMonth.split('-').map(Number)
if (!y || !m) return yearMonth
try {
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
} catch { return yearMonth }
}
return (
<div className="supporter-section">
<style>{`
.supporter-section { margin-top: 20px; }
.supporter-card {
position: relative;
border-radius: 20px;
padding: 22px 22px 18px;
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
border: 1px solid rgba(99,102,241,0.18);
overflow: hidden;
}
.supporter-glow {
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
animation: supporterGlow 6s ease-in-out infinite;
}
.supporter-header {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 6px;
}
.supporter-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
background-size: 200% 100%;
animation: supporterShimmer 4s ease-in-out infinite;
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
white-space: nowrap;
}
.supporter-title {
margin: 0; font-size: 16px; font-weight: 700;
color: var(--text-primary); letter-spacing: -0.01em;
}
.supporter-subtitle {
position: relative; z-index: 1;
margin: 0 0 16px; font-size: 12.5px;
color: var(--text-secondary); line-height: 1.55;
}
.supporter-tiers {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 10px;
}
.supporter-tier {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 12px; border-radius: 14px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
}
.supporter-tier-icon {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.supporter-tier-body { flex: 1; min-width: 0; }
.supporter-tier-head {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
}
.supporter-tier-label {
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
}
.supporter-tier-price {
font-size: 11px; font-weight: 600; color: var(--text-faint);
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
}
.supporter-tier-chips {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
}
.supporter-tier-empty {
font-size: 11.5px; font-style: italic; color: var(--text-faint);
}
.supporter-chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 10px; border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s;
max-width: 100%;
}
.supporter-chip-name {
font-size: 12px; font-weight: 600; color: var(--text-primary);
white-space: nowrap;
}
.supporter-chip-since {
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
white-space: nowrap;
}
.supporter-chip-since-short { display: none; }
@keyframes supporterShimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes supporterGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.75; }
}
@media (max-width: 640px) {
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
.supporter-glow { inset: -40px; }
.supporter-header { gap: 8px; }
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
.supporter-title { font-size: 15px; flex-basis: 100%; }
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
.supporter-tier-label { font-size: 13px; }
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
.supporter-chip { padding: 3px 9px; }
.supporter-chip-since { font-size: 10px; }
.supporter-chip-since-full { display: none; }
.supporter-chip-since-short { display: inline; }
}
`}</style>
<div className="supporter-card">
<div className="supporter-glow" />
<div className="supporter-header">
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
</div>
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
<div className="supporter-tiers">
{SUPPORTER_TIERS.map(tier => {
const members = SUPPORTERS.filter(s => s.tier === tier.id)
const empty = members.length === 0
const TierIcon = tier.icon
return (
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
<TierIcon size={18} strokeWidth={2.2} />
</div>
<div className="supporter-tier-body">
<div className="supporter-tier-head">
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
<span className="supporter-tier-price">{tier.price}</span>
</div>
<div className="supporter-tier-chips">
{empty && (
<span className="supporter-tier-empty">
{t('settings.about.supporters.tierEmpty')}
</span>
)}
{members.map(m => {
const chipContent = (
<>
<span className="supporter-chip-name">{m.username}</span>
<span className="supporter-chip-since supporter-chip-since-full">
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
</span>
<span className="supporter-chip-since supporter-chip-since-short">
· {formatSince(m.since)}
</span>
</>
)
return m.link ? (
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
{chipContent}
</a>
) : (
<div key={m.username} className="supporter-chip">{chipContent}</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t } = useTranslation()
const { t, locale } = useTranslation()
return (
<Section title={t('settings.about')} icon={Info}>
@@ -141,6 +362,8 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<SupporterSection t={t} locale={locale} />
</Section>
)
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
@@ -70,159 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
: DefaultIcon;
return (
<div className="flex flex-col relative">
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Dismiss"
>
<X size={18} />
</button>
)}
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
className="w-full overflow-hidden"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown (long body text uses left-aligned layout) */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a
href={href}
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
>
{body}
</ReactMarkdown>
</React.Suspense>
</div>
{/* Inline image */}
{notice.media?.placement === 'inline' && (
{/* Scrollable content — vertically centered when shorter than available space */}
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
className="w-full overflow-hidden"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</li>
);
})}
</ul>
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a
href={href}
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
>
{body}
</ReactMarkdown>
</React.Suspense>
</div>
{/* Inline image */}
{notice.media?.placement === 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</li>
);
})}
</ul>
)}
</div>
</div>
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
<div
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
>
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1 mb-4">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={!canPage || currentPage === 0}
aria-label={t('system_notice.pager.prev')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={14} />
</button>
@@ -246,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
onClick={onNext}
disabled={!canPage || currentPage === total - 1}
aria-label={t('system_notice.pager.next')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={14} />
</button>
@@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{!isLastPage && total > 1 ? (
/* Non-last page: "Next" button to advance through all notices */
<button
id={`notice-cta-${notice.id}`}
onClick={onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
{t('system_notice.pager.next')} <ChevronRight size={16} />
</button>
) : ctaLabel ? (
<div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
@@ -279,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
>
{ctaLabel}
</button>
) : (
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
onClick={isLastPage ? onDismissAll : onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
@@ -327,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
// Non-dismissible notices lock the pager so users must act before advancing.
const canPage = notice?.dismissible !== false;
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
const dragLockRef = useRef<'h' | 'v' | null>(null);
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
// when the user is scrolled into content and pans down to scroll back up.
const scrollTopAtTouchStart = useRef(0);
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
const noticeIdRef = useRef<string | null>(null);
noticeIdRef.current = notice?.id ?? null;
@@ -339,7 +346,15 @@ export function ModalRenderer({ notices }: Props) {
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
const isPageNavRef = useRef(false);
const slideDirRef = useRef<'left' | 'right'>('right');
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
const stripRef = useRef<HTMLDivElement>(null);
// The sheet element itself — animated on vertical drag-to-dismiss
const sheetRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
// Individual slot scroll containers (prev / center / next)
const prevSlotRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
const nextSlotRef = useRef<HTMLDivElement>(null);
// Mobile breakpoint
useEffect(() => {
@@ -455,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
useEffect(() => {
if (!isMobile) return;
contentWrapperRef.current?.scrollTo({ top: 0 });
}, [idx, isMobile]);
function announceIndex(newIdx: number, total: number) {
setPageAnnouncement(
t('system_notice.pager.position')
@@ -498,6 +519,17 @@ export function ModalRenderer({ notices }: Props) {
}
}
function animatedDismissAll() {
const sheet = sheetRef.current;
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
sheet.style.transition = 'transform 300ms ease-out';
sheet.style.transform = 'translateY(110%)';
sheet.addEventListener('transitionend', function onDone() {
sheet.removeEventListener('transitionend', onDone);
handleDismissAll();
}, { once: true });
}
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
// re-renders with the new notice), then flags the grace-delay effect to slide
// rather than hide+show.
@@ -576,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
@@ -583,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
}
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}
}}
>
{/* Drag handle */}
<div className="pt-3 pb-1 flex justify-center">
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
</div>
@@ -37,9 +37,10 @@ describe('TodoListPanel', () => {
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
});
it('FE-COMP-TODO-002: shows Add new task button', () => {
render(<TodoListPanel tripId={1} items={[]} />);
expect(screen.getByText('Add new task...')).toBeInTheDocument();
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
});
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
@@ -119,11 +120,9 @@ describe('TodoListPanel', () => {
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
});
it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
const user = userEvent.setup();
render(<TodoListPanel tripId={1} items={[]} />);
await user.click(screen.getByText('Add new task...'));
// The detail pane shows "Create task" button
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
});
@@ -398,15 +397,12 @@ describe('TodoListPanel', () => {
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
}),
);
render(<TodoListPanel tripId={1} items={[]} />);
// Open the new task pane
await user.click(screen.getByText('Add new task...'));
// Wait for "Create task" button to appear
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
// Raising the signal opens the new task pane (simulates the toolbar button click)
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
// Type a task name in the autoFocus input (Task name placeholder)
const nameInput = screen.getByPlaceholderText('Task name');
await user.type(nameInput, 'Brand New Task');
// Click the Create task button
await user.click(screen.getByText('Create task'));
await waitFor(() => expect(postCalled).toBe(true));
});
+3 -3
View File
@@ -386,7 +386,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<DetailPane
@@ -402,7 +402,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
<NewTaskPane
@@ -420,7 +420,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import type { Place } from '../../types'
interface Category {
@@ -18,10 +19,12 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => {
if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current
if (!el) return
// Check if already cached — show immediately without waiting for intersection
@@ -37,6 +40,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
+28
View File
@@ -313,6 +313,16 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'اقتراح ميزة',
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
'settings.about.wikiHint': 'التوثيق والأدلة',
'settings.about.supporters.badge': 'الداعمون الشهريون',
'settings.about.supporters.title': 'رفاق رحلة TREK',
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
'settings.about.supporters.since': 'داعم منذ {date}',
'settings.about.supporters.tierEmpty': 'كن الأول',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
'settings.about.madeWith': 'صُنع بـ',
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
@@ -588,6 +598,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'صور الأماكن',
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
'admin.placesDetails.title': 'تفاصيل الأماكن',
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
'admin.bagTracking.title': 'تتبع الأمتعة',
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
'admin.collab.chat.title': 'الدردشة',
@@ -1579,6 +1595,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
'journey.search.placeholder': 'البحث في الرحلات…',
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
'journey.status.archived': 'مؤرشف',
'journey.settings.endJourney': 'أرشفة الرحلة',
'journey.settings.reopenJourney': 'استعادة الرحلة',
'journey.settings.archived': 'تم أرشفة الرحلة',
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
@@ -1889,6 +1913,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
'admin.tabs.notifications': 'الإشعارات',
'notifications.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
+28
View File
@@ -240,6 +240,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Solicitar recurso',
'settings.about.featureRequestHint': 'Sugira um novo recurso',
'settings.about.wikiHint': 'Documentação e guias',
'settings.about.supporters.badge': 'Apoiadores Mensais',
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
'settings.about.supporters.since': 'apoiador desde {date}',
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
'settings.about.madeWith': 'Feito com',
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
@@ -546,6 +556,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Fotos de Locais',
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
'admin.collab.chat.title': 'Chat',
@@ -1838,6 +1854,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
'admin.tabs.notifications': 'Notificações',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
@@ -1885,6 +1905,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
'journey.search.placeholder': 'Buscar jornadas…',
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
'journey.title': 'Jornada',
'journey.subtitle': 'Registre suas viagens em tempo real',
'journey.new': 'Nova jornada',
@@ -1906,6 +1928,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Ativa',
'journey.status.completed': 'Concluída',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Arquivado',
'journey.checkin.add': 'Fazer check-in',
'journey.checkin.namePlaceholder': 'Nome do local',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -2059,6 +2082,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
'journey.settings.endJourney': 'Arquivar Jornada',
'journey.settings.reopenJourney': 'Restaurar Jornada',
'journey.settings.archived': 'Jornada arquivada',
'journey.settings.reopened': 'Jornada reaberta',
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
'journey.settings.delete': 'Excluir',
'journey.settings.deleteJourney': 'Excluir jornada',
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
+28
View File
@@ -264,6 +264,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Navrhnout funkci',
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
'settings.about.wikiHint': 'Dokumentace a návody',
'settings.about.supporters.badge': 'Měsíční podporovatelé',
'settings.about.supporters.title': 'Společníci na cestě s TREK',
'settings.about.supporters.subtitle': 'Zatímco plánuješ další trasu, tihle lidé plánují společně se mnou budoucnost TREK. Jejich měsíční příspěvek jde přímo na vývoj a reálně strávené hodiny — aby TREK zůstal Open Source.',
'settings.about.supporters.since': 'podporovatel od {date}',
'settings.about.supporters.tierEmpty': 'Buď první',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
'settings.about.madeWith': 'Vytvořeno s',
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
@@ -546,6 +556,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Nastavení souborů uloženo',
// Šablony balení (Packing Templates)
'admin.placesPhotos.title': 'Fotografie míst',
'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.',
'admin.placesAutocomplete.title': 'Automatické doplňování míst',
'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.',
'admin.placesDetails.title': 'Podrobnosti o místě',
'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.',
'admin.bagTracking.title': 'Sledování zavazadel',
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
'admin.collab.chat.title': 'Chat',
@@ -1843,6 +1859,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
'admin.notifications.tripReminders.title': 'Připomínky výletů',
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
'admin.tabs.notifications': 'Oznámení',
'notifications.versionAvailable.title': 'Dostupná aktualizace',
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
@@ -1890,6 +1910,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
'journey.search.placeholder': 'Hledat cesty…',
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
'journey.title': 'Cestovní deník',
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
'journey.new': 'Nový cestovní deník',
@@ -1911,6 +1933,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktivní',
'journey.status.completed': 'Dokončeno',
'journey.status.upcoming': 'Nadcházející',
'journey.status.archived': 'Archivováno',
'journey.checkin.add': 'Odbavit se',
'journey.checkin.namePlaceholder': 'Název místa',
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
@@ -2064,6 +2087,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Název',
'journey.settings.subtitle': 'Podtitul',
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
'journey.settings.endJourney': 'Archivovat cestu',
'journey.settings.reopenJourney': 'Obnovit cestu',
'journey.settings.archived': 'Cesta archivována',
'journey.settings.reopened': 'Cesta znovu otevřena',
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
'journey.settings.delete': 'Smazat',
'journey.settings.deleteJourney': 'Smazat cestovní deník',
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
+28
View File
@@ -313,6 +313,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Feature vorschlagen',
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
'settings.about.supporters.badge': 'Monatliche Unterstützer',
'settings.about.supporters.title': 'Reisebegleitung für TREK',
'settings.about.supporters.subtitle': 'Während du deine nächste Route planst, planen diese Leute mit, wie TREK weitergeht. Ihr monatlicher Beitrag fließt direkt in Entwicklung und echten Zeitaufwand — damit TREK Open Source bleibt.',
'settings.about.supporters.since': 'Unterstützer seit {date}',
'settings.about.supporters.tierEmpty': 'Sei die/der Erste',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
'settings.about.madeWith': 'Entwickelt mit',
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
@@ -551,6 +561,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
'admin.placesPhotos.title': 'Ortsfotos',
'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.',
'admin.placesAutocomplete.title': 'Orts-Autovervollständigung',
'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.',
'admin.placesDetails.title': 'Ortsdetails',
'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.',
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Gepäck-Tracking',
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
@@ -1846,6 +1862,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
'admin.tabs.notifications': 'Benachrichtigungen',
'notifications.versionAvailable.title': 'Update verfügbar',
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
@@ -1887,6 +1907,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
// Journey Addon
'journey.search.placeholder': 'Reisen suchen…',
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
'journey.new': 'Neue Journey',
@@ -1908,6 +1930,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktiv',
'journey.status.completed': 'Abgeschlossen',
'journey.status.upcoming': 'Anstehend',
'journey.status.archived': 'Archiviert',
'journey.checkin.add': 'Einchecken',
'journey.checkin.namePlaceholder': 'Ortsname',
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
@@ -2065,6 +2088,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Untertitel',
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
'journey.settings.endJourney': 'Reise archivieren',
'journey.settings.reopenJourney': 'Reise wiederherstellen',
'journey.settings.archived': 'Reise archiviert',
'journey.settings.reopened': 'Reise erneut geöffnet',
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
'journey.settings.delete': 'Löschen',
'journey.settings.deleteJourney': 'Journey löschen',
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
+28
View File
@@ -253,6 +253,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
'admin.notifications.tripReminders.title': 'Trip Reminders',
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
@@ -368,6 +372,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Feature Request',
'settings.about.featureRequestHint': 'Suggest a new feature',
'settings.about.wikiHint': 'Documentation & guides',
'settings.about.supporters.badge': 'Monthly Supporters',
'settings.about.supporters.title': 'Travel companions for TREK',
'settings.about.supporters.subtitle': "While you're planning your next route, these folks are helping plan TREK's future. Their monthly contribution goes straight into development and real hours spent — so TREK stays Open Source.",
'settings.about.supporters.since': 'supporter since {date}',
'settings.about.supporters.tierEmpty': 'Be the first',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
'settings.about.madeWith': 'Made with',
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
@@ -607,6 +621,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
'admin.placesPhotos.title': 'Place Photos',
'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.',
'admin.placesAutocomplete.title': 'Place Autocomplete',
'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.',
'admin.placesDetails.title': 'Place Details',
'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.',
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Bag Tracking',
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
@@ -1890,6 +1910,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Search journeys…',
'journey.search.noResults': 'No journeys match "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Track your travels as they happen',
'journey.new': 'New Journey',
@@ -1911,6 +1933,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Active',
'journey.status.completed': 'Completed',
'journey.status.upcoming': 'Upcoming',
'journey.status.archived': 'Archived',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Location name',
'journey.checkin.notesPlaceholder': 'Notes (optional)',
@@ -2089,6 +2112,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Subtitle',
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
'journey.settings.endJourney': 'Archive Journey',
'journey.settings.reopenJourney': 'Restore Journey',
'journey.settings.archived': 'Journey archived',
'journey.settings.reopened': 'Journey reopened',
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
'journey.settings.delete': 'Delete',
'journey.settings.deleteJourney': 'Delete Journey',
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
+28
View File
@@ -309,6 +309,16 @@ const es: Record<string, string> = {
'settings.about.featureRequest': 'Solicitar función',
'settings.about.featureRequestHint': 'Sugiere una nueva función',
'settings.about.wikiHint': 'Documentación y guías',
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
'settings.about.supporters.title': 'Compañía de viaje para TREK',
'settings.about.supporters.subtitle': 'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
'settings.about.supporters.since': 'patrocinador desde {date}',
'settings.about.supporters.tierEmpty': 'Sé el primero',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
'settings.about.madeWith': 'Hecho con',
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
@@ -541,6 +551,12 @@ const es: Record<string, string> = {
'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
'admin.placesPhotos.title': 'Fotos de Lugares',
'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
'admin.placesDetails.title': 'Detalles del Lugar',
'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
'admin.bagTracking.title': 'Seguimiento de equipaje',
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
'admin.collab.chat.title': 'Chat',
@@ -1848,6 +1864,10 @@ const es: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
'admin.tabs.notifications': 'Notificaciones',
'notifications.versionAvailable.title': 'Actualización disponible',
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
@@ -1892,6 +1912,8 @@ const es: Record<string, string> = {
'common.justNow': 'justo ahora',
'common.hoursAgo': 'hace {count}h',
'common.daysAgo': 'hace {count}d',
'journey.search.placeholder': 'Buscar viajes…',
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
'journey.title': 'Travesía',
'journey.subtitle': 'Registra tus viajes en tiempo real',
'journey.new': 'Nueva travesía',
@@ -1913,6 +1935,7 @@ const es: Record<string, string> = {
'journey.status.active': 'Activa',
'journey.status.completed': 'Completada',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Archivado',
'journey.checkin.add': 'Registrar ubicación',
'journey.checkin.namePlaceholder': 'Nombre del lugar',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -2066,6 +2089,11 @@ const es: Record<string, string> = {
'journey.settings.name': 'Nombre',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
'journey.settings.endJourney': 'Archivar viaje',
'journey.settings.reopenJourney': 'Restaurar viaje',
'journey.settings.archived': 'Viaje archivado',
'journey.settings.reopened': 'Viaje reabierto',
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
'journey.settings.delete': 'Eliminar',
'journey.settings.deleteJourney': 'Eliminar travesía',
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
+28
View File
@@ -308,6 +308,16 @@ const fr: Record<string, string> = {
'settings.about.featureRequest': 'Proposer une fonctionnalité',
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
'settings.about.wikiHint': 'Documentation et guides',
'settings.about.supporters.badge': 'Soutiens Mensuels',
'settings.about.supporters.title': 'Compagnons de voyage pour TREK',
'settings.about.supporters.subtitle': 'Pendant que tu planifies ton prochain itinéraire, ces personnes aident à planifier l\'avenir de TREK. Leur contribution mensuelle va directement au développement et aux heures réellement passées — pour que TREK reste Open Source.',
'settings.about.supporters.since': 'soutien depuis {date}',
'settings.about.supporters.tierEmpty': 'Sois le premier',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
'settings.about.madeWith': 'Fait avec',
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
@@ -545,6 +555,12 @@ const fr: Record<string, string> = {
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
'admin.placesPhotos.title': 'Photos de lieux',
'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.",
'admin.placesAutocomplete.title': 'Autocomplétion des lieux',
'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.",
'admin.placesDetails.title': 'Détails du lieu',
'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.",
'admin.bagTracking.title': 'Suivi des bagages',
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
'admin.collab.chat.title': 'Chat',
@@ -1842,6 +1858,10 @@ const fr: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
'admin.notifications.tripReminders.title': 'Rappels de voyage',
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
'admin.tabs.notifications': 'Notifications',
'notifications.versionAvailable.title': 'Mise à jour disponible',
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
@@ -1886,6 +1906,8 @@ const fr: Record<string, string> = {
'common.justNow': 'à l\'instant',
'common.hoursAgo': 'il y a {count}h',
'common.daysAgo': 'il y a {count}j',
'journey.search.placeholder': 'Rechercher des journaux…',
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
'journey.title': 'Journal de voyage',
'journey.subtitle': 'Suivez vos voyages en temps réel',
'journey.new': 'Nouveau journal',
@@ -1907,6 +1929,7 @@ const fr: Record<string, string> = {
'journey.status.active': 'Actif',
'journey.status.completed': 'Terminé',
'journey.status.upcoming': 'À venir',
'journey.status.archived': 'Archivé',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nom du lieu',
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
@@ -2060,6 +2083,11 @@ const fr: Record<string, string> = {
'journey.settings.name': 'Nom',
'journey.settings.subtitle': 'Sous-titre',
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
'journey.settings.endJourney': 'Archiver le journal',
'journey.settings.reopenJourney': 'Restaurer le journal',
'journey.settings.archived': 'Journal archivé',
'journey.settings.reopened': 'Journal rouvert',
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
'journey.settings.delete': 'Supprimer',
'journey.settings.deleteJourney': 'Supprimer le journal',
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
+28
View File
@@ -263,6 +263,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Funkció javaslat',
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
'settings.about.supporters.badge': 'Havi támogatók',
'settings.about.supporters.title': 'Útitársak a TREK mellett',
'settings.about.supporters.subtitle': 'Miközben te a következő útvonaladat tervezed, ők a TREK jövőjét tervezik velem együtt. Havi hozzájárulásuk közvetlenül fejlesztésre és valódi órákra fordítódik — hogy a TREK Open Source maradhasson.',
'settings.about.supporters.since': 'támogató {date} óta',
'settings.about.supporters.tierEmpty': 'Légy az első',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
'settings.about.madeWith': 'Készítve',
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
@@ -546,6 +556,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
// Csomagolási sablonok és poggyászkövetés
'admin.placesPhotos.title': 'Helyfotók',
'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.',
'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése',
'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.',
'admin.placesDetails.title': 'Hely részletei',
'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.',
'admin.bagTracking.title': 'Poggyászkövetés',
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
'admin.collab.chat.title': 'Chat',
@@ -1840,6 +1856,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
'admin.tabs.notifications': 'Értesítések',
'notifications.versionAvailable.title': 'Elérhető frissítés',
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
@@ -1887,6 +1907,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
'journey.search.placeholder': 'Utak keresése…',
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
'journey.title': 'Útinaplók',
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
'journey.new': 'Új útinapló',
@@ -1908,6 +1930,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktív',
'journey.status.completed': 'Befejezett',
'journey.status.upcoming': 'Közelgő',
'journey.status.archived': 'Archivált',
'journey.checkin.add': 'Bejelentkezés',
'journey.checkin.namePlaceholder': 'Helyszín neve',
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
@@ -2061,6 +2084,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Név',
'journey.settings.subtitle': 'Alcím',
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
'journey.settings.endJourney': 'Út archiválása',
'journey.settings.reopenJourney': 'Út visszaállítása',
'journey.settings.archived': 'Út archiválva',
'journey.settings.reopened': 'Út újranyitva',
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
'journey.settings.delete': 'Törlés',
'journey.settings.deleteJourney': 'Útinapló törlése',
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
+28
View File
@@ -251,6 +251,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
'admin.smtp.title': 'Email & Notifikasi',
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
'admin.smtp.testButton': 'Kirim email uji',
@@ -366,6 +370,16 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Permintaan Fitur',
'settings.about.featureRequestHint': 'Sarankan fitur baru',
'settings.about.wikiHint': 'Dokumentasi & panduan',
'settings.about.supporters.badge': 'Pendukung Bulanan',
'settings.about.supporters.title': 'Rekan perjalanan untuk TREK',
'settings.about.supporters.subtitle': 'Saat kamu merencanakan rute berikutnya, orang-orang ini ikut merencanakan masa depan TREK. Kontribusi bulanan mereka langsung masuk ke pengembangan dan jam kerja nyata — supaya TREK tetap Open Source.',
'settings.about.supporters.since': 'pendukung sejak {date}',
'settings.about.supporters.tierEmpty': 'Jadilah yang pertama',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
'settings.about.madeWith': 'Dibuat dengan',
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
@@ -606,6 +620,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Pengaturan jenis file disimpan',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto Tempat',
'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.',
'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat',
'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.',
'admin.placesDetails.title': 'Detail Tempat',
'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.',
'admin.bagTracking.title': 'Pelacak Tas',
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
'admin.collab.chat.title': 'Chat',
@@ -1890,6 +1910,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Cari perjalanan…',
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
'journey.new': 'Journey Baru',
@@ -1911,6 +1933,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktif',
'journey.status.completed': 'Selesai',
'journey.status.upcoming': 'Mendatang',
'journey.status.archived': 'Diarsipkan',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Nama lokasi',
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
@@ -2088,6 +2111,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nama',
'journey.settings.subtitle': 'Subjudul',
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
'journey.settings.endJourney': 'Arsipkan Perjalanan',
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
'journey.settings.archived': 'Perjalanan diarsipkan',
'journey.settings.reopened': 'Perjalanan dibuka kembali',
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
'journey.settings.delete': 'Hapus',
'journey.settings.deleteJourney': 'Hapus Journey',
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
+28
View File
@@ -263,6 +263,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Richiedi funzionalità',
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
'settings.about.wikiHint': 'Documentazione e guide',
'settings.about.supporters.badge': 'Sostenitori Mensili',
'settings.about.supporters.title': 'Compagni di viaggio per TREK',
'settings.about.supporters.subtitle': 'Mentre pianifichi il tuo prossimo itinerario, queste persone aiutano a pianificare il futuro di TREK. Il loro contributo mensile va direttamente allo sviluppo e alle ore realmente investite — per mantenere TREK Open Source.',
'settings.about.supporters.since': 'sostenitore da {date}',
'settings.about.supporters.tierEmpty': 'Sii il primo',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
'settings.about.madeWith': 'Fatto con',
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
@@ -545,6 +555,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto dei luoghi',
'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.",
'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi',
'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.",
'admin.placesDetails.title': 'Dettagli del luogo',
'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.",
'admin.bagTracking.title': 'Tracciamento valigia',
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
'admin.collab.chat.title': 'Chat',
@@ -1843,6 +1859,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
'admin.tabs.notifications': 'Notifiche',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
@@ -1887,6 +1907,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.justNow': 'proprio ora',
'common.hoursAgo': '{count}h fa',
'common.daysAgo': '{count}g fa',
'journey.search.placeholder': 'Cerca viaggi…',
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
'journey.title': 'Diario di viaggio',
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
'journey.new': 'Nuovo diario',
@@ -1908,6 +1930,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Attivo',
'journey.status.completed': 'Completato',
'journey.status.upcoming': 'In arrivo',
'journey.status.archived': 'Archiviato',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nome del luogo',
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
@@ -2061,6 +2084,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Sottotitolo',
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
'journey.settings.endJourney': 'Archivia il viaggio',
'journey.settings.reopenJourney': 'Ripristina il viaggio',
'journey.settings.archived': 'Viaggio archiviato',
'journey.settings.reopened': 'Viaggio riaperto',
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
'journey.settings.delete': 'Elimina',
'journey.settings.deleteJourney': 'Elimina diario',
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
+28
View File
@@ -308,6 +308,16 @@ const nl: Record<string, string> = {
'settings.about.featureRequest': 'Feature aanvragen',
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
'settings.about.wikiHint': 'Documentatie en handleidingen',
'settings.about.supporters.badge': 'Maandelijkse Steuners',
'settings.about.supporters.title': 'Reisgezelschap voor TREK',
'settings.about.supporters.subtitle': 'Terwijl jij je volgende route plant, plannen deze mensen mee aan de toekomst van TREK. Hun maandelijkse bijdrage gaat rechtstreeks naar ontwikkeling en echte uren — zodat TREK Open Source blijft.',
'settings.about.supporters.since': 'steuner sinds {date}',
'settings.about.supporters.tierEmpty': 'Wees de eerste',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
'settings.about.madeWith': 'Gemaakt met',
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
@@ -546,6 +556,12 @@ const nl: Record<string, string> = {
'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
'admin.placesPhotos.title': "Plaatsfoto's",
'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.",
'admin.placesAutocomplete.title': 'Plaatsautocomplete',
'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.',
'admin.placesDetails.title': 'Plaatsdetails',
'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.',
'admin.bagTracking.title': 'Bagagetracking',
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
'admin.collab.chat.title': 'Chat',
@@ -1842,6 +1858,10 @@ const nl: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
'admin.notifications.tripReminders.title': 'Reisherinneringen',
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
'admin.tabs.notifications': 'Meldingen',
'notifications.versionAvailable.title': 'Update beschikbaar',
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
@@ -1886,6 +1906,8 @@ const nl: Record<string, string> = {
'common.justNow': 'zojuist',
'common.hoursAgo': '{count}u geleden',
'common.daysAgo': '{count}d geleden',
'journey.search.placeholder': 'Reizen zoeken…',
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
'journey.title': 'Reisverslag',
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
'journey.new': 'Nieuw reisverslag',
@@ -1907,6 +1929,7 @@ const nl: Record<string, string> = {
'journey.status.active': 'Actief',
'journey.status.completed': 'Voltooid',
'journey.status.upcoming': 'Gepland',
'journey.status.archived': 'Gearchiveerd',
'journey.checkin.add': 'Inchecken',
'journey.checkin.namePlaceholder': 'Locatienaam',
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
@@ -2060,6 +2083,11 @@ const nl: Record<string, string> = {
'journey.settings.name': 'Naam',
'journey.settings.subtitle': 'Ondertitel',
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
'journey.settings.endJourney': 'Reis archiveren',
'journey.settings.reopenJourney': 'Reis herstellen',
'journey.settings.archived': 'Reis gearchiveerd',
'journey.settings.reopened': 'Reis heropend',
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
'journey.settings.delete': 'Verwijderen',
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
+28
View File
@@ -281,6 +281,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Zaproponuj funkcję',
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
'settings.about.wikiHint': 'Dokumentacja i poradniki',
'settings.about.supporters.badge': 'Miesięczni Patroni',
'settings.about.supporters.title': 'Towarzystwo podróży dla TREK',
'settings.about.supporters.subtitle': 'Gdy planujesz kolejną trasę, te osoby planują razem ze mną przyszłość TREK. Ich comiesięczny wkład idzie bezpośrednio na rozwój i realnie przepracowane godziny — aby TREK pozostał Open Source.',
'settings.about.supporters.since': 'patron od {date}',
'settings.about.supporters.tierEmpty': 'Bądź pierwszy',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
'settings.about.madeWith': 'Stworzone z',
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
@@ -518,6 +528,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Zdjęcia miejsc',
'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.',
'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc',
'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.',
'admin.placesDetails.title': 'Szczegóły miejsca',
'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.',
'admin.bagTracking.title': 'Kontrola bagażu',
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
'admin.collab.chat.title': 'Czat',
@@ -1642,6 +1658,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
@@ -1879,6 +1899,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
'journey.search.placeholder': 'Szukaj podróży…',
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
'journey.title': 'Dziennik podróży',
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
'journey.new': 'Nowy dziennik podróży',
@@ -1900,6 +1922,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktywny',
'journey.status.completed': 'Zakończony',
'journey.status.upcoming': 'Nadchodzący',
'journey.status.archived': 'Zarchiwizowano',
'journey.checkin.add': 'Zamelduj się',
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
@@ -2053,6 +2076,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nazwa',
'journey.settings.subtitle': 'Podtytuł',
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
'journey.settings.endJourney': 'Archiwizuj podróż',
'journey.settings.reopenJourney': 'Przywróć podróż',
'journey.settings.archived': 'Podróż zarchiwizowana',
'journey.settings.reopened': 'Podróż wznowiona',
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
'journey.settings.delete': 'Usuń',
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
+28
View File
@@ -308,6 +308,16 @@ const ru: Record<string, string> = {
'settings.about.featureRequest': 'Предложить функцию',
'settings.about.featureRequestHint': 'Предложите новую функцию',
'settings.about.wikiHint': 'Документация и руководства',
'settings.about.supporters.badge': 'Ежемесячные спонсоры',
'settings.about.supporters.title': 'Спутники TREK',
'settings.about.supporters.subtitle': 'Пока ты планируешь следующий маршрут, эти люди планируют вместе со мной будущее TREK. Их ежемесячный взнос идёт напрямую в разработку и реально потраченные часы — чтобы TREK оставался Open Source.',
'settings.about.supporters.since': 'спонсор с {date}',
'settings.about.supporters.tierEmpty': 'Стань первым',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
'settings.about.madeWith': 'Сделано с',
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
@@ -546,6 +556,12 @@ const ru: Record<string, string> = {
'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
'admin.placesPhotos.title': 'Фотографии мест',
'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.',
'admin.placesAutocomplete.title': 'Автодополнение мест',
'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.',
'admin.placesDetails.title': 'Сведения о месте',
'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.',
'admin.bagTracking.title': 'Отслеживание багажа',
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
'admin.collab.chat.title': 'Чат',
@@ -1839,6 +1855,10 @@ const ru: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
'admin.tabs.notifications': 'Уведомления',
'notifications.versionAvailable.title': 'Доступно обновление',
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
@@ -1886,6 +1906,8 @@ const ru: Record<string, string> = {
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'journey.search.placeholder': 'Поиск путешествий…',
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие',
@@ -1907,6 +1929,7 @@ const ru: Record<string, string> = {
'journey.status.active': 'Активно',
'journey.status.completed': 'Завершено',
'journey.status.upcoming': 'Предстоящее',
'journey.status.archived': 'В архиве',
'journey.checkin.add': 'Отметиться',
'journey.checkin.namePlaceholder': 'Название места',
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
@@ -2060,6 +2083,11 @@ const ru: Record<string, string> = {
'journey.settings.name': 'Название',
'journey.settings.subtitle': 'Подзаголовок',
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
'journey.settings.endJourney': 'Архивировать путешествие',
'journey.settings.reopenJourney': 'Восстановить путешествие',
'journey.settings.archived': 'Путешествие архивировано',
'journey.settings.reopened': 'Путешествие возобновлено',
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
'journey.settings.delete': 'Удалить',
'journey.settings.deleteJourney': 'Удалить путешествие',
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
+28
View File
@@ -308,6 +308,16 @@ const zh: Record<string, string> = {
'settings.about.featureRequest': '功能建议',
'settings.about.featureRequestHint': '建议一个新功能',
'settings.about.wikiHint': '文档和指南',
'settings.about.supporters.badge': '月度支持者',
'settings.about.supporters.title': '与 TREK 同行的伙伴',
'settings.about.supporters.subtitle': '当你在规划下一段路线时,这些人也在一起规划 TREK 的未来。他们每月的支持直接用于开发与真实投入的时间——让 TREK 保持开源。',
'settings.about.supporters.since': '{date} 起的支持者',
'settings.about.supporters.tierEmpty': '成为第一个',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
'settings.about.madeWith': '用',
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
@@ -546,6 +556,12 @@ const zh: Record<string, string> = {
'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
'admin.fileTypesSaved': '文件类型设置已保存',
'admin.placesPhotos.title': '地点照片',
'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。',
'admin.placesAutocomplete.title': '地点自动补全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。',
'admin.placesDetails.title': '地点详情',
'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。',
'admin.bagTracking.title': '行李追踪',
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
'admin.collab.chat.title': '聊天',
@@ -1839,6 +1855,10 @@ const zh: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
'admin.tabs.notifications': '通知',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
@@ -1886,6 +1906,8 @@ const zh: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段',
'journey.search.placeholder': '搜索旅程…',
'journey.search.noResults': '没有与"{query}"匹配的旅程',
'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程',
@@ -1907,6 +1929,7 @@ const zh: Record<string, string> = {
'journey.status.active': '进行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即将开始',
'journey.status.archived': '已归档',
'journey.checkin.add': '签到',
'journey.checkin.namePlaceholder': '地点名称',
'journey.checkin.notesPlaceholder': '备注(可选)',
@@ -2060,6 +2083,11 @@ const zh: Record<string, string> = {
'journey.settings.name': '名称',
'journey.settings.subtitle': '副标题',
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
'journey.settings.endJourney': '归档旅程',
'journey.settings.reopenJourney': '恢复旅程',
'journey.settings.archived': '旅程已归档',
'journey.settings.reopened': '旅程已重新开启',
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
'journey.settings.delete': '删除',
'journey.settings.deleteJourney': '删除旅程',
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
+28
View File
@@ -251,6 +251,10 @@ const zhTw: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
'admin.smtp.title': '郵件與通知',
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
'admin.smtp.testButton': '傳送測試郵件',
@@ -363,6 +367,16 @@ const zhTw: Record<string, string> = {
'settings.about.featureRequest': '功能建議',
'settings.about.featureRequestHint': '建議新功能',
'settings.about.wikiHint': '文件與指南',
'settings.about.supporters.badge': '月度支持者',
'settings.about.supporters.title': '與 TREK 同行的夥伴',
'settings.about.supporters.subtitle': '當你規劃下一段路線時,這些人也在一起規劃 TREK 的未來。他們每月的支持直接用於開發與實際投入的時間——讓 TREK 保持開源。',
'settings.about.supporters.since': '自 {date} 起的支持者',
'settings.about.supporters.tierEmpty': '成為第一個',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
@@ -602,6 +616,12 @@ const zhTw: Record<string, string> = {
'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。',
'admin.fileTypesSaved': '檔案型別設定已儲存',
'admin.placesPhotos.title': '地點照片',
'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。',
'admin.placesAutocomplete.title': '地點自動補全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。',
'admin.placesDetails.title': '地點詳情',
'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。',
'admin.bagTracking.title': '行李追蹤',
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
'admin.collab.chat.title': '聊天',
@@ -1846,6 +1866,8 @@ const zhTw: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位',
'journey.search.placeholder': '搜尋旅程…',
'journey.search.noResults': '沒有符合「{query}」的旅程',
'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程',
@@ -1867,6 +1889,7 @@ const zhTw: Record<string, string> = {
'journey.status.active': '進行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即將開始',
'journey.status.archived': '已封存',
'journey.checkin.add': '打卡',
'journey.checkin.namePlaceholder': '地點名稱',
'journey.checkin.notesPlaceholder': '備註(可選)',
@@ -2020,6 +2043,11 @@ const zhTw: Record<string, string> = {
'journey.settings.name': '名稱',
'journey.settings.subtitle': '副標題',
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
'journey.settings.endJourney': '封存旅程',
'journey.settings.reopenJourney': '還原旅程',
'journey.settings.archived': '旅程已封存',
'journey.settings.reopened': '旅程已重新開啟',
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
'journey.settings.delete': '刪除',
'journey.settings.deleteJourney': '刪除旅程',
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
+105 -1
View File
@@ -194,6 +194,18 @@ export default function AdminPage(): React.ReactElement {
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
// Collab features
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
@@ -242,7 +254,7 @@ export default function AdminPage(): React.ReactElement {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -1023,6 +1035,66 @@ export default function AdminPage(): React.ReactElement {
)}
</div>
{/* Place Photos Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesPhotosEnabled
setPlacesPhotosEnabledState(next)
setPlacesPhotosEnabled(next)
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Autocomplete Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesAutocompleteEnabled
setPlacesAutocompleteEnabledState(next)
setPlacesAutocompleteEnabled(next)
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Details Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesDetailsEnabled
setPlacesDetailsEnabledState(next)
setPlacesDetailsEnabled(next)
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Open-Meteo Weather Info */}
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
<div className="px-4 py-3 flex items-center justify-between">
@@ -1180,6 +1252,7 @@ export default function AdminPage(): React.ReactElement {
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
@@ -1338,6 +1411,37 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Trip Reminders Toggle */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
</div>
<button
onClick={async () => {
const next = !tripRemindersActive
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
try {
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch {
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
toast.error(t('common.error'))
}
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: tripRemindersActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
+47 -33
View File
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
avatar: null,
},
],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
};
// ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
const timelineBtn = screen.getByRole('button', { name: /timeline/i });
expect(timelineBtn).toBeInTheDocument();
// Timeline entries are visible by default
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
// Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
});
});
@@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => {
describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => {
it('renders all entry titles in timeline view', async () => {
await renderAndWait();
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
});
it('renders stat values', async () => {
await renderAndWait();
// stats.entries = 2, stats.photos = 1, stats.cities = 2
// stats.entries = 2, stats.photos = 1, stats.places = 2
// Entries count appears in hero and sidebar
const twos = screen.getAllByText('2');
expect(twos.length).toBeGreaterThanOrEqual(1);
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
it('shows "No entries yet" when journey has no entries', async () => {
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
render(<JourneyDetailPage />);
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
});
it('shows hint text to add a trip', async () => {
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
render(<JourneyDetailPage />);
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 3, cities: 2 },
stats: { entries: 2, photos: 3, places: 2 },
});
render(<JourneyDetailPage />);
@@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, skeletonEntry],
stats: { entries: 3, photos: 1, cities: 3 },
stats: { entries: 3, photos: 1, places: 3 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
});
// Skeleton card shows "Add Entry" CTA
@@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, checkinEntry],
stats: { entries: 3, photos: 1, cities: 2 },
stats: { entries: 3, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
});
});
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
it('renders a "Live" badge for active journeys', async () => {
it('renders a "Live" badge when linked trip spans today', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('does not render "Live" badge when linked trip is in the past', async () => {
await renderAndWait();
expect(screen.queryByText('Live')).not.toBeInTheDocument();
});
});
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
it('renders the "Synced with Trips" text in the hero', async () => {
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
});
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
it('shows the place count in the sidebar map', async () => {
await renderAndWait();
// The sidebar map shows "N Places" text
expect(screen.getByText(/Places/)).toBeInTheDocument();
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
});
});
@@ -1106,8 +1117,9 @@ describe('JourneyDetailPage', () => {
// Map view renders a location list with entry titles/location names
// The MapView component shows entry names in clickable location items
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
// (timeline is still mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1166,8 +1178,8 @@ describe('JourneyDetailPage', () => {
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
// Each day group shows its entries
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1717,7 +1729,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [emptyEntry],
stats: { entries: 1, photos: 0, cities: 1 },
stats: { entries: 1, photos: 0, places: 1 },
});
render(<JourneyDetailPage />);
@@ -1867,8 +1879,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
});
// Click the "Arrived in Rome" location item
const romeItem = screen.getByText('Arrived in Rome');
// Click the "Arrived in Rome" location item in the map view's location list
// (timeline is still mounted but hidden, so find the one inside a cursor-pointer container)
const romeItems = screen.getAllByText('Arrived in Rome');
const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0];
await user.click(romeItem);
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
@@ -1930,7 +1944,7 @@ describe('JourneyDetailPage', () => {
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
];
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
render(<JourneyDetailPage />);
await waitFor(() => {
@@ -2005,7 +2019,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2039,7 +2053,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2636,7 +2650,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 5, cities: 2 },
stats: { entries: 2, photos: 5, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2661,7 +2675,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
render(<JourneyDetailPage />);
@@ -3045,7 +3059,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noLocEntry],
stats: { entries: 2, photos: 1, cities: 1 },
stats: { entries: 2, photos: 1, places: 1 },
});
render(<JourneyDetailPage />);
@@ -3528,7 +3542,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
server.use(
@@ -3620,7 +3634,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noTitleEntry],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
+103 -34
View File
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -163,6 +164,12 @@ export default function JourneyDetailPage() {
setActiveLocationId(id)
}, [])
useEffect(() => {
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
}
}, [view])
const mapEntries = useMemo(
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
@@ -207,6 +214,14 @@ export default function JourneyDetailPage() {
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const tripDateMin = current.trips.length
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
: null
const tripDateMax = current.trips.length
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
: null
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
const showMobileCombined = isMobile && view === 'timeline'
return (
@@ -283,16 +298,28 @@ export default function JourneyDetailPage() {
<div className="relative z-[3] flex items-center justify-between mb-5">
{/* Desktop: badges */}
<div className="hidden md:flex items-center gap-2">
{current.status === 'active' && (
{lifecycle === 'live' && (
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Live
{t('journey.frontpage.live')}
</div>
)}
{lifecycle !== 'archived' && current.trips.length > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
)}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
</div>
)}
{lifecycle === 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t('journey.status.archived')}
</div>
)}
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
</div>
{/* Mobile: back button on the left */}
<button
@@ -331,7 +358,7 @@ export default function JourneyDetailPage() {
<div className="flex gap-8">
{[
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
].map(s => (
@@ -392,8 +419,8 @@ export default function JourneyDetailPage() {
</div>
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && view === 'timeline' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{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">
@@ -448,7 +475,7 @@ export default function JourneyDetailPage() {
)}
{/* Gallery View */}
{view === 'gallery' && (
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
@@ -457,17 +484,21 @@ export default function JourneyDetailPage() {
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
)}
</div>
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/></div>}
{!isMobile && (
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
<MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/>
</div>
)}
</div>
{/* Right sidebar — hidden on mobile */}
@@ -494,7 +525,7 @@ export default function JourneyDetailPage() {
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
@@ -1021,7 +1052,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)}
onAdd={async (assetIds, entryId) => {
onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
@@ -1034,10 +1065,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return }
}
let added = 0
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
added = result.added || 0
} catch {}
for (const group of groups) {
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} catch {}
}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
@@ -1511,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -1525,7 +1558,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -1617,8 +1650,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
const next = new Map(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
}
return next
})
}
@@ -1780,9 +1817,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<button
onClick={() => {
if (allSelected) {
setSelected(new Set())
setSelected(new Map())
} else {
setSelected(new Set(selectable.map((a: any) => a.id)))
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -1884,7 +1921,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
onClick={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
className="px-3.5 py-2 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 disabled:cursor-not-allowed"
>
@@ -2091,7 +2137,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
return (
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2820,6 +2866,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [archiving, setArchiving] = useState(false)
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
const handleDelete = async () => {
try {
@@ -2947,11 +3008,19 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
>
<Trash2 size={13} />
{t('journey.settings.delete')}
</button>
<button
onClick={handleArchiveToggle}
disabled={archiving}
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
title={t('journey.settings.endDescription')}
>
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
</button>
<button onClick={onClose} className="px-3.5 py-2 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="px-3.5 py-2 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')}
+16 -8
View File
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
status: 'draft' as const,
entry_count: 0,
photo_count: 0,
city_count: 0,
place_count: 0,
trip_date_min: null as string | null,
trip_date_max: null as string | null,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
setupDefaultHandlers([active, other]);
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
});
// FE-PAGE-JOURNEY-013
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
const j1 = buildJourneyListItem({
id: 20,
title: 'Stats Journey',
entry_count: 12,
photo_count: 47,
city_count: 5,
place_count: 5,
});
setupDefaultHandlers([j1]);
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
});
// The card renders entry_count, photo_count, city_count values
// The card renders entry_count, photo_count, place_count values
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
id: 40,
title: 'Recent Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 60000, // 1 minute ago
});
setupDefaultHandlers([active]);
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
id: 41,
title: 'Hours Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
});
setupDefaultHandlers([active]);
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
id: 42,
title: 'Days Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
});
setupDefaultHandlers([active]);
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-018
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-019
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-020
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
const user = userEvent.setup();
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
+106 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
@@ -10,6 +10,7 @@ import {
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -43,6 +44,9 @@ export default function JourneyPage() {
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
@@ -56,12 +60,22 @@ export default function JourneyPage() {
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
return journeys.find(j => j.status === 'active') || null
}, [journeys])
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const otherJourneys = useMemo(() => {
return journeys.filter(j => j.id !== activeJourney?.id)
}, [journeys, activeJourney])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
@@ -99,15 +113,41 @@ export default function JourneyPage() {
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile: just a create button */}
<div className="md:hidden px-5 pt-5 pb-4">
<button
onClick={() => openCreateModal()}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
{/* Header — mobile */}
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => {
if (searchOpen) {
setSearchOpen(false)
setSearchQuery('')
} else {
setSearchOpen(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}
}}
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
>
{searchOpen ? <X size={15} /> : <Search size={15} />}
</button>
<button
onClick={() => openCreateModal()}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{searchOpen && (
<input
ref={searchInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
placeholder={t('journey.search.placeholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
/>
)}
</div>
{/* Header — desktop */}
@@ -117,8 +157,24 @@ export default function JourneyPage() {
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
<Search size={15} />
{searchOpen && (
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
placeholder={t('journey.search.placeholder')}
autoFocus
className="w-52 px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-[10px] text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
/>
)}
<button
onClick={() => {
setSearchOpen(s => !s)
if (searchOpen) setSearchQuery('')
}}
className={`w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-zinc-500 transition-colors ${searchOpen ? 'bg-zinc-100 dark:bg-zinc-700' : 'bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700'}`}
>
{searchOpen ? <X size={15} /> : <Search size={15} />}
</button>
<button
onClick={() => openCreateModal()}
@@ -226,7 +282,7 @@ export default function JourneyPage() {
{[
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
@@ -243,11 +299,24 @@ export default function JourneyPage() {
</div>
)}
{/* Search results info */}
{searchQuery.trim() && (
<div className="mb-4 flex items-center gap-2">
<span className="text-[13px] text-zinc-500">
{filteredJourneys.length === 0
? t('journey.search.noResults', { query: searchQuery.trim() })
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
</span>
</div>
)}
{/* All Journeys */}
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
{!searchQuery.trim() && (
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
)}
{loading && journeys.length === 0 ? (
<div className="flex justify-center py-16">
@@ -255,7 +324,7 @@ export default function JourneyPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
{otherJourneys.map(j => (
{filteredJourneys.map(j => (
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
))}
@@ -279,7 +348,7 @@ export default function JourneyPage() {
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
@@ -386,12 +455,13 @@ export default function JourneyPage() {
)
}
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
const { t } = useTranslation()
const j = journey
const entryCount = j.entry_count ?? 0
const photoCount = j.photo_count ?? 0
const cityCount = j.city_count ?? 0
const placeCount = j.place_count ?? 0
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
return (
<div
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
{j.subtitle && (
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
)}
{j.status === 'draft' && (
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
{lifecycle !== 'live' && (
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
}`}>
{t(`journey.status.${lifecycle}`)}
</span>
)}
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
{[
{ val: entryCount, label: t('journey.stats.entries') },
{ val: photoCount, label: t('journey.stats.photos') },
{ val: cityCount, label: t('journey.stats.cities') },
{ val: placeCount, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
+3 -3
View File
@@ -109,7 +109,7 @@ const mockJourneyData = {
stats: {
entries: 2,
photos: 1,
cities: 2,
places: 2,
},
};
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
],
},
],
stats: { entries: 1, photos: 3, cities: 0 },
stats: { entries: 1, photos: 3, places: 0 },
};
server.use(
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
const customData = {
...mockJourneyData,
stats: { entries: 14, photos: 83, cities: 7 },
stats: { entries: 14, photos: 83, places: 7 },
};
server.use(
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
+1 -1
View File
@@ -176,7 +176,7 @@ export default function JourneyPublicPage() {
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
</div>
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
+6 -4
View File
@@ -28,6 +28,7 @@ import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { offlineDb } from '../db/offlineDb'
import { useAuthStore } from '../store/authStore'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
@@ -128,7 +129,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
</div>
</div>
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} />}
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} inlineHeader={false} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
</div>
</div>
@@ -141,6 +142,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
const places = useTripStore(s => s.places)
@@ -261,7 +263,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Start photo fetches during splash screen so images are ready when map mounts
useEffect(() => {
if (isLoading || !places || places.length === 0) return
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
for (const p of places) {
if (p.image_url) continue
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
@@ -932,7 +934,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{selectedPlace && isMobile && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
<PlaceInspector
place={selectedPlace}
@@ -994,7 +996,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
</div>
+13
View File
@@ -85,6 +85,19 @@ export function fetchPhoto(
return
}
// If photoId is already our stable proxy URL, use it directly — no API round-trip needed
if (photoId && photoId.startsWith('/api/maps/place-photo/')) {
const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
// Generate base64 thumb in background
urlToBase64(photoId).then(thumb => {
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
})
return
}
inFlight.add(cacheKey)
mapsApi.placePhoto(photoId, lat, lng, name)
.then(async (data: { photoUrl?: string }) => {
+12
View File
@@ -33,6 +33,9 @@ interface AuthState {
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
placesPhotosEnabled: boolean
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -53,6 +56,9 @@ interface AuthState {
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
setPlacesPhotosEnabled: (val: boolean) => void
setPlacesAutocompleteEnabled: (val: boolean) => void
setPlacesDetailsEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -74,6 +80,9 @@ export const useAuthStore = create<AuthState>()(
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
placesPhotosEnabled: true,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
login: async (email: string, password: string) => {
authSequence++
@@ -257,6 +266,9 @@ export const useAuthStore = create<AuthState>()(
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }),
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
demoLogin: async () => {
authSequence++
+2 -2
View File
@@ -8,7 +8,7 @@ export interface Journey {
subtitle?: string | null
cover_gradient?: string | null
cover_image?: string | null
status: 'draft' | 'active' | 'completed'
status: 'draft' | 'active' | 'completed' | 'archived'
created_at: number
updated_at: number
}
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number }
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
}
+32
View File
@@ -0,0 +1,32 @@
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
export function computeJourneyLifecycle(
status: string,
tripDateMin: string | null | undefined,
tripDateMax: string | null | undefined,
): JourneyLifecycle {
if (status === 'archived') return 'archived'
if (tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
if (tripDateMin <= today && today <= tripDateMax) return 'live'
if (tripDateMin > today) return 'upcoming'
return 'completed'
}
if (!tripDateMin && !tripDateMax) {
return 'draft'
}
// Single boundary: only start or only end
if (tripDateMin && !tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMin > today ? 'upcoming' : 'live'
}
if (!tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMax < today ? 'completed' : 'live'
}
return 'completed'
}
+19 -8
View File
@@ -69,10 +69,10 @@ There are **no database rows for notice definitions**. The registry is code-only
├── reads user_notice_dismissals
├── filters SYSTEM_NOTICES:
not dismissed
not expired (expiresAt)
within [minVersion, maxVersion) range for the running app version
all conditions pass (AND logic)
├── sorts by priority → severity → publishedAt (desc)
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
└── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority)
6. Client receives SystemNoticeDTO[]
@@ -138,7 +138,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
**Never remove or renumber an entry. Never reuse an ID.**
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the upper version on which it should appear (e.g. `4.0.0` means show notice until `4.0.0` is reached) — do not delete the entry.
---
@@ -162,13 +162,16 @@ Dismissals are stored in the database keyed by `id`. Removing an entry means dis
| Field | Type | Description |
|---|---|---|
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
| `minVersion` | `string` | Lowest app version (inclusive, semver) that should show this notice. Omit for no lower bound. |
| `maxVersion` | `string` | Upper bound (exclusive, semver) — notice is hidden once this version ships. `maxVersion: '4.0.0'` means shown on `< 4.0.0`. Omit for no upper bound. |
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
> **Version bounds:** The range is `[minVersion, maxVersion)` — lower bound inclusive, upper bound exclusive. So `maxVersion: '4.0.0'` hides the notice once the app reaches 4.0.0. Both bounds are compared after stripping prerelease/build metadata via `semver.coerce`, so a server running `3.0.0-pre.42` is treated as `3.0.0` — consistent with `existingUserBeforeVersion` and staging environments behave like production.
### `NoticeMedia`
```typescript
@@ -596,17 +599,25 @@ The registry integrity test will catch any `actionId` that appears in the regist
### Retire a notice (stop showing it)
**Do not delete the entry.** Set `expiresAt`:
**Do not delete the entry.** Set `maxVersion` to the last app version on which the notice should appear. Once the app is upgraded past that version, the service filters it out automatically. The database row for dismissed users remains harmless.
```typescript
{
id: 'old-campaign',
// ... all existing fields unchanged ...
expiresAt: '2026-07-01T00:00:00Z',
maxVersion: '3.1.0', // hidden once 3.1.0 ships (exclusive upper bound)
}
```
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
To scope a notice to a specific version window (e.g. a v3-only announcement), combine both bounds:
```typescript
{
id: 'v3-only',
minVersion: '3.0.0',
maxVersion: '4.0.0', // shown on >= 3.0.0 and < 4.0.0
}
```
---
@@ -751,4 +762,4 @@ cd client && npm run test -- SystemNoticeModal
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
| `expiresAt` over deletion for retiring notices | See above. |
| `maxVersion` over deletion for retiring notices | See §12. Deletion would cause dismissed users to re-see the notice if the ID were ever reused. |
+49 -1
View File
@@ -1634,7 +1634,55 @@ function runMigrations(db: Database.Database): void {
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
// Migration 105: Persistent Google place photo disk cache registry
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
place_id TEXT PRIMARY KEY,
attribution TEXT,
fetched_at INTEGER NOT NULL,
error_at INTEGER
)
`);
},
// Migration 106: Persistent Place Details row cache
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS place_details_cache (
place_id TEXT NOT NULL,
lang TEXT NOT NULL DEFAULT '',
expanded INTEGER NOT NULL DEFAULT 0,
payload_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (place_id, lang, expanded)
)
`);
},
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
{ raw: () => {
db.exec(`
UPDATE places
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
updated_at = CURRENT_TIMESTAMP
WHERE google_place_id IS NOT NULL
AND image_url IS NOT NULL
AND image_url != ''
AND (
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
)
`);
}},
// Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology)
() => db.exec(`
CREATE TABLE IF NOT EXISTS trek_photo_cache_meta (
cache_key TEXT PRIMARY KEY,
content_type TEXT NOT NULL DEFAULT 'image/jpeg',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
`),
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
+1
View File
@@ -49,6 +49,7 @@ const server = app.listen(PORT, () => {
scheduler.startVersionCheck();
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+57
View File
@@ -201,6 +201,63 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
res.json(result);
});
// ── Places Photos ───────────────────────────────────────────────────────
router.get('/places-photos', (_req: Request, res: Response) => {
res.json(svc.getPlacesPhotos());
});
router.put('/places-photos', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesPhotos(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_photos',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Autocomplete ──────────────────────────────────────────────────
router.get('/places-autocomplete', (_req: Request, res: Response) => {
res.json(svc.getPlacesAutocomplete());
});
router.put('/places-autocomplete', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesAutocomplete(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_autocomplete',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Details ───────────────────────────────────────────────────────
router.get('/places-details', (_req: Request, res: Response) => {
res.json(svc.getPlacesDetails());
});
router.put('/places-details', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesDetails(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_details',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Collab Features ───────────────────────────────────────────────────────
router.get('/collab-features', (_req: Request, res: Response) => {
+4 -3
View File
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
+29 -1
View File
@@ -4,11 +4,14 @@ import { AuthRequest } from '../types';
import {
searchPlaces,
getPlaceDetails,
getPlaceDetailsExpanded,
getPlacePhoto,
reverseGeocode,
resolveGoogleMapsUrl,
autocompletePlaces,
} from '../services/mapsService';
import { db } from '../db/database';
import { serveFilePath } from '../services/placePhotoCache';
const router = express.Router();
@@ -32,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
// POST /autocomplete
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
const authReq = req as AuthRequest;
const { input, lang, locationBias } = req.body;
@@ -70,11 +76,18 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
// GET /details/:placeId
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
const authReq = req as AuthRequest;
const { placeId } = req.params;
const expand = req.query.expand as string | undefined;
try {
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
const refresh = req.query.refresh === '1';
const result = expand
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
@@ -88,6 +101,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
if (!placeId.startsWith('coords:')) {
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
}
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
@@ -102,6 +121,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
}
});
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
const { placeId } = req.params;
const fp = serveFilePath(placeId);
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.sendFile(fp);
});
// GET /reverse
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
+27 -8
View File
@@ -166,14 +166,9 @@ function startTripReminders(): void {
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
const hasWebhook = activeChannels.includes('webhook');
const channelReady = hasEmail || hasWebhook;
if (!channelReady || !reminderEnabled) {
if (!reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
li(`Trip reminders: disabled (${reason})`);
li('Trip reminders: disabled in settings');
return;
}
@@ -253,12 +248,36 @@ function startIdempotencyCleanup(): void {
}, { timezone: tz });
}
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
let trekPhotoCacheTask: ScheduledTask | null = null;
function startTrekPhotoCacheCleanup(): void {
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
// Run once immediately on startup to evict any entries left over from a previous run
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch { /* cache dir may not exist yet — harmless */ }
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
}
});
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
+36
View File
@@ -459,6 +459,42 @@ export function updateBagTracking(enabled: boolean) {
return { enabled: !!enabled };
}
// ── Places Photos ─────────────────────────────────────────────────────────
export function getPlacesPhotos() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesPhotos(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Places Autocomplete ────────────────────────────────────────────────────
export function getPlacesAutocomplete() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesAutocomplete(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Places Details ─────────────────────────────────────────────────────────
export function getPlacesDetails() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesDetails(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
+11 -2
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
@@ -227,8 +228,13 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value;
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value;
const placesDetailsEnabled = placesDetailsSetting !== 'false';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
@@ -258,6 +264,9 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
notification_channels: activeChannels,
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
trip_reminders_enabled: tripRemindersEnabled,
places_photos_enabled: placesPhotosEnabled,
places_autocomplete_enabled: placesAutocompleteEnabled,
places_details_enabled: placesDetailsEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined,
dev_mode: process.env.NODE_ENV === 'development',
};
+12 -7
View File
@@ -1,7 +1,7 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
function ts(): number {
return Date.now();
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
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_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 city_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 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
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
}
export function createJourney(userId: number, data: {
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...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(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
fields.push(`${key} = ?`);
values.push(val);
}
@@ -628,12 +632,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
@@ -714,6 +718,7 @@ export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & {
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(photo.photo_id);
// clean up empty Gallery entries left behind
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
+1 -1
View File
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
+186 -89
View File
@@ -2,6 +2,19 @@ import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard';
// ── Google API call counter ───────────────────────────────────────────────────
let googleApiCallCount = 0;
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label}${endpoint}`);
return fetch(endpoint, init);
}
// ── Interfaces ───────────────────────────────────────────────────────────────
interface NominatimResult {
@@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult {
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// ── Photo cache ──────────────────────────────────────────────────────────────
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of photoCache) {
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
}
if (photoCache.size > CACHE_MAX_ENTRIES) {
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => photoCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
// ── API key retrieval ────────────────────────────────────────────────────────
@@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
return { places, source: 'openstreetmap' };
}
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -371,7 +366,7 @@ export async function autocompletePlaces(
};
}
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
// Google details
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) {
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
}
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
const cached = db.prepare(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
},
});
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const place = {
google_place_id: data.id,
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
lng: data.location?.longitude || null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: null,
reviews: [],
source: 'google' as const,
cached_at: Date.now(),
};
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
return { place };
}
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
// Check DB cache for expanded result
if (!refresh) {
const cached = db.prepare(
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
).get(placeId, langKey) as { payload_json: string } | undefined;
if (cached) return { place: JSON.parse(cached.payload_json) };
}
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
@@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
photo: r.authorAttribution?.photoUri || null,
})),
source: 'google' as const,
cached_at: Date.now(),
};
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
}
return { place };
}
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
export async function getPlacePhoto(
userId: number,
@@ -508,84 +579,110 @@ export async function getPlacePhoto(
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null }> {
// Check cache first
const cached = photoCache.get(placeId);
if (cached) {
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
if (Date.now() - cached.fetchedAt < ttl) {
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
// Disk cache hit — serve immediately, no Google call
const diskHit = placePhotoCache.get(placeId);
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
// Recent error — don't hammer the API
if (placePhotoCache.getErrored(placeId)) {
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
}
// Deduplicate concurrent requests for the same placeId
const existing = placePhotoCache.getInFlight(placeId);
if (existing) {
const result = await existing;
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
// Wikimedia photos: fetch bytes and cache to disk
const ssrf = await checkSsrf(wiki.photoUrl, true);
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
const imgRes = await fetch(wiki.photoUrl);
if (imgRes.ok) {
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
}
}
} catch { /* fall through */ }
}
placePhotoCache.markError(placeId);
return null;
}
photoCache.delete(placeId);
}
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
// No Google key or coordinate-only lookup -> try Wikimedia
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
return wiki;
} else {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
}
} catch { /* fall through */ }
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
placePhotoCache.markError(placeId);
return null;
}
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
}
// Google Photos
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
return null;
}
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
if (!details.photos?.length) {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
}
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
if (!mediaRes.ok) {
placePhotoCache.markError(placeId);
return null;
}
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
placePhotoCache.markError(placeId);
return null;
}
if (!photoUrl) {
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
}
const cached = await placePhotoCache.put(placeId, bytes, attribution);
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
// Persist photo URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
).run(photoUrl, placeId, '');
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
})();
return { photoUrl, attribution };
placePhotoCache.setInFlight(placeId, fetchPromise);
const result = await fetchPromise;
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
// ── Reverse geocoding ────────────────────────────────────────────────────────
@@ -230,6 +230,30 @@ export async function getAssetInfo(
}
}
export async function fetchImmichThumbnailBytes(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
try {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
});
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function streamImmichAsset(
response: Response,
userId: number,
@@ -3,11 +3,12 @@ import path from 'path';
import fs from 'fs';
import { db } from '../../db/database';
import type { TrekPhoto } from '../../types';
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache';
// ── Lookup / Register ────────────────────────────────────────────────────
@@ -22,7 +23,7 @@ export function getOrCreateTrekPhoto(
).get(provider, assetId, ownerId) as { id: number } | undefined;
if (existing) {
if (passphrase) {
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?')
.run(encrypt_api_key(passphrase), existing.id);
}
return existing.id;
@@ -57,6 +58,36 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
// ── Streaming ────────────────────────────────────────────────────────────
async function streamCachedThumbnail(
res: Response,
photo: TrekPhoto,
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
fallback: () => Promise<unknown>,
): Promise<void> {
const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
if (photoCache.serveFresh(res, key)) return;
const existing = photoCache.getInFlight(key);
if (existing) {
const bytes = await existing;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
return;
}
const promise = fetchBytes().then(async result => {
if ('error' in result) return null;
await photoCache.put(key, result.bytes, result.contentType);
return result.bytes;
});
photoCache.setInFlight(key, promise);
const bytes = await promise;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
}
export async function streamPhoto(
res: Response,
userId: number,
@@ -84,11 +115,27 @@ export async function streamPhoto(
return;
}
case 'immich': {
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!),
);
return;
}
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
return;
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
() => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
);
return;
}
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
return;
}
@@ -145,6 +192,19 @@ export function setTrekPhotoProvider(
).run(provider, assetId, ownerId, trekPhotoId);
}
// ── Orphan cleanup ───────────────────────────────────────────────────────
export function deleteTrekPhotoIfOrphan(photoId: number): void {
const stillUsed = db.prepare(`
SELECT 1 FROM trip_photos WHERE photo_id = ?
UNION ALL
SELECT 1 FROM journey_photos WHERE photo_id = ?
LIMIT 1
`).get(photoId, photoId);
if (stillUsed) return;
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
}
// ── Delete local file for a trek_photo ──────────────────────────────────
export function getTrekPhotoFilePath(photoId: number): string | null {
+55 -21
View File
@@ -604,6 +604,47 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ
return success(normalized);
}
export async function fetchSynologyThumbnailBytes(
userId: number,
targetUserId: number,
photoId: string,
passphrase?: string,
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await _getSynologySession(targetUserId);
if (!sid.success) return { error: 'Session error', status: 500 };
if (!sid.data) return { error: 'Session ID missing', status: 500 };
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: 'sm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
const resp = await safeFetch(url);
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function streamSynologyAsset(
response: Response,
userId: number,
@@ -637,27 +678,20 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const resolvedSize = size || 'sm';
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
: new URLSearchParams({
api: 'SYNO.Foto.Download',
method: 'download',
version: '2',
cache_key: parsedId.cacheKey,
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
@@ -0,0 +1,91 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { Response } from 'express';
import { db } from '../../db/database';
const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek');
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const inFlight = new Map<string, Promise<Buffer | null>>();
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
}
function ensureDir(): void {
if (!fs.existsSync(TREK_PHOTO_DIR)) {
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
}
}
function cachedFilePath(key: string): string {
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
}
export function getFresh(key: string): { filePath: string; contentType: string } | null {
const row = db.prepare(
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
).get(key) as { content_type: string; fetched_at: number } | undefined;
if (!row) return null;
if (Date.now() - row.fetched_at >= CACHE_TTL) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
const fp = cachedFilePath(key);
if (!fs.existsSync(fp)) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
return { filePath: fp, contentType: row.content_type };
}
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
ensureDir();
const fp = cachedFilePath(key);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
db.prepare(
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
).run(key, contentType, Date.now());
}
export function serveFresh(res: Response, key: string): boolean {
const entry = getFresh(key);
if (!entry) return false;
res.set('Content-Type', entry.contentType);
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(entry.filePath);
return true;
}
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
return inFlight.get(key);
}
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
inFlight.set(key, promise);
promise.finally(() => inFlight.delete(key));
}
export function sweepExpired(): void {
const cutoff = Date.now() - CACHE_TTL * 2;
const stale = db.prepare(
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
).all(cutoff) as { cache_key: string }[];
for (const row of stale) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
const fp = cachedFilePath(row.cache_key);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
}
+10 -2
View File
@@ -8,7 +8,7 @@ import {
mapDbError,
Selection,
} from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from './photoResolverService';
import { encrypt_api_key } from '../apiKeyCrypto';
@@ -212,6 +212,7 @@ export function removeTripPhoto(
AND photo_id = ?
`).run(tripId, userId, photoId);
deleteTrekPhotoIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
@@ -269,13 +270,20 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
}
try {
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
db.transaction(() => {
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
})();
for (const { photo_id } of linkedPhotos) {
deleteTrekPhotoIfOrphan(photo_id);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
+95
View File
@@ -0,0 +1,95 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { db } from '../db/database';
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
const ERROR_TTL = 5 * 60 * 1000;
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
function ensureDir(): void {
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
}
}
function filePath(placeId: string): string {
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
}
function proxyUrl(placeId: string): string {
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
}
interface CachedPhoto {
photoUrl: string;
filePath: string;
attribution: string | null;
}
export function get(placeId: string): CachedPhoto | null {
const row = db.prepare(
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
).get(placeId) as { attribution: string | null } | undefined;
if (!row) return null;
const fp = filePath(placeId);
if (!fs.existsSync(fp)) {
// File missing (e.g. volume wiped) — clear row so it refetches
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
return null;
}
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
}
export function getErrored(placeId: string): boolean {
const row = db.prepare(
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
).get(placeId) as { error_at: number } | undefined;
if (!row) return false;
return Date.now() - row.error_at < ERROR_TTL;
}
export function markError(placeId: string): void {
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
).run(placeId, Date.now(), Date.now());
}
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
ensureDir();
const fp = filePath(placeId);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
).run(placeId, attribution, Date.now());
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
}
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
return inFlight.get(placeId);
}
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
inFlight.set(placeId, promise);
promise.finally(() => inFlight.delete(placeId));
}
export function serveFilePath(placeId: string): string | null {
const fp = filePath(placeId);
return fs.existsSync(fp) ? fp : null;
}
+10
View File
@@ -27,6 +27,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 90,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -55,6 +57,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 80,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -78,6 +82,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 75,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -98,6 +104,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 70,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -112,6 +120,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 95,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
// ── Onboarding ─────────────────────────────────────────────────────────────
+28 -4
View File
@@ -1,10 +1,34 @@
import { createRequire } from 'module';
import semver from 'semver';
import { db } from '../db/database.js';
import { SYSTEM_NOTICES } from './registry.js';
import { evaluate } from './conditions.js';
import type { SystemNoticeDTO } from './types.js';
import type { SystemNotice, SystemNoticeDTO } from './types.js';
function getCurrentAppVersion(): string {
return process.env.APP_VERSION || '0.0.0';
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
if (fromEnv) return fromEnv;
try {
const pkg = require('../../package.json') as { version?: string };
return semver.valid(pkg.version ?? '') ?? '0.0.0';
} catch {
return '0.0.0';
}
}
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
if (n.minVersion !== undefined) {
const min = semver.valid(n.minVersion);
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
if (semver.lt(appVersion, min)) return false;
}
if (n.maxVersion !== undefined) {
const max = semver.valid(n.maxVersion);
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
if (semver.gte(appVersion, max)) return false;
}
return true;
}
function severityWeight(s: string): number {
@@ -35,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
.sort((a, b) => {
@@ -45,7 +69,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
+3 -2
View File
@@ -37,9 +37,10 @@ export interface SystemNotice {
dismissible: boolean;
conditions: NoticeCondition[];
publishedAt: string;
expiresAt?: string;
minVersion?: string;
maxVersion?: string;
priority?: number;
}
// DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
+1 -1
View File
@@ -329,7 +329,7 @@ export interface Journey {
subtitle?: string | null;
cover_gradient?: string | null;
cover_image?: string | null;
status: 'draft' | 'active' | 'completed';
status: 'draft' | 'active' | 'completed' | 'archived';
created_at: number;
updated_at: number;
}
+44
View File
@@ -936,6 +936,50 @@ describe('Share link update', () => {
});
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Provider photos passphrase (JOURNEY-INT-046, 047)
// ─────────────────────────────────────────────────────────────────────────────
describe('Provider photos — passphrase persistence', () => {
it('JOURNEY-INT-046 — single mode with passphrase persists encrypted passphrase on trek_photos', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_id: 'shared-asset-1', passphrase: 'pp-test' });
expect(res.status).toBe(201);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
});
it('JOURNEY-INT-047 — batch mode with passphrase persists encrypted passphrase on all trek_photos rows', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_ids: ['batch-asset-1', 'batch-asset-2'], passphrase: 'pp-batch' });
expect(res.status).toBe(201);
expect(res.body.added).toBe(2);
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
}
});
});
// Photo upload without files (JOURNEY-INT-045)
// ─────────────────────────────────────────────────────────────────────────────
@@ -166,16 +166,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
});
}
// Original download
if (apiName === 'SYNO.Foto.Download') {
const imageBytes = Buffer.from('fake-synology-original');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
}
@@ -1179,3 +1169,74 @@ describe('Synology SSRF blocked error handling', () => {
expect(res.body.albums.length).toBeGreaterThan(0);
});
});
// ── Passphrase persistence fixes ─────────────────────────────────────────────
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../src/services/memories/photoResolverService';
import { decrypt_api_key } from '../../src/services/apiKeyCrypto';
describe('trek_photos passphrase healing (SYNO-090)', () => {
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
const { user } = createUser(testDb);
const wrongPass = 'wrong-passphrase';
const correctPass = 'correct-passphrase';
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
expect(id2).toBe(id1);
const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
});
});
describe('trek_photos orphan cleanup (SYNO-091)', () => {
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, trekPhotoId);
// Still referenced — must not be deleted.
deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
});
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
const firstPass = 'first-passphrase';
const secondPass = 'second-passphrase';
// Add with wrong passphrase, then remove (simulating the bug scenario).
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, id1);
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
deleteTrekPhotoIfOrphan(id1);
// trek_photos row should be gone.
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();
// Re-add with the correct passphrase.
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
});
});
@@ -107,9 +107,11 @@ describe('GET /api/system-notices/active', () => {
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(testNotice).toBeDefined();
// DTO should not expose conditions, publishedAt, expiresAt, priority
// DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority
expect(testNotice.conditions).toBeUndefined();
expect(testNotice.publishedAt).toBeUndefined();
expect(testNotice.minVersion).toBeUndefined();
expect(testNotice.maxVersion).toBeUndefined();
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
@@ -202,7 +202,7 @@ describe('listJourneys', () => {
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Road Trip');
expect(result[0].entry_count).toBe(2);
expect(result[0].city_count).toBe(2);
expect(result[0].place_count).toBe(2);
});
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
@@ -226,6 +226,21 @@ describe('listJourneys', () => {
expect(result).toHaveLength(0);
});
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
addTripToJourney(journey.id, trip1.id, user.id);
addTripToJourney(journey.id, trip2.id, user.id);
const result = listJourneys(user.id);
expect(result).toHaveLength(1);
expect(result[0].trip_date_min).toBe('2025-06-01');
expect(result[0].trip_date_max).toBe('2026-03-20');
});
});
describe('createJourney (service)', () => {
@@ -335,6 +350,26 @@ describe('updateJourney', () => {
expect(result).not.toBeNull();
expect(result!.title).toBe('Same');
});
it('JOURNEY-SVC-021b: accepts archived status', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
const result = updateJourney(journey.id, user.id, { status: 'archived' });
expect(result).not.toBeNull();
expect(result!.status).toBe('archived');
});
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
expect(result).not.toBeNull();
expect(result!.status).toBe('active');
});
});
describe('deleteJourney', () => {
@@ -1412,3 +1447,24 @@ describe('Edge cases', () => {
expect(filledRow.source_place_id).toBeNull();
});
});
// -- Passphrase on addProviderPhoto -------------------------------------------
describe('addProviderPhoto — passphrase', () => {
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
expect(photo).not.toBeNull();
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
// stored value must be encrypted (not plaintext)
expect(row?.passphrase).not.toBe('secret-pp');
});
});
@@ -336,7 +336,7 @@ describe('getPublicJourney', () => {
expect(result!.entries).toHaveLength(2);
expect(result!.stats.entries).toBe(2);
expect(result!.stats.photos).toBe(1);
expect(result!.stats.cities).toBe(2);
expect(result!.stats.places).toBe(2);
expect(result!.permissions.share_timeline).toBe(true);
expect(result!.permissions.share_gallery).toBe(true);
expect(result!.permissions.share_map).toBe(false);
@@ -397,6 +397,6 @@ describe('getPublicJourney', () => {
expect(result!.entries).toEqual([]);
expect(result!.stats.entries).toBe(0);
expect(result!.stats.photos).toBe(0);
expect(result!.stats.cities).toBe(0);
expect(result!.stats.places).toBe(0);
});
});
+107 -64
View File
@@ -8,10 +8,19 @@
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
const { mockDbGet, mockDbRun, mockCheckSsrf } = vi.hoisted(() => ({
const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({
mockDbGet: vi.fn(() => undefined as any),
mockDbRun: vi.fn(),
mockCheckSsrf: vi.fn(async () => ({ allowed: true })),
mockCacheGet: vi.fn(() => null as any),
mockCacheGetErrored: vi.fn(() => false),
mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
filePath: `/tmp/${placeId}.jpg`,
attribution,
})),
mockCacheGetInFlight: vi.fn(() => undefined),
mockCacheSetInFlight: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
@@ -33,6 +42,16 @@ vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: '0'.repeat(64),
}));
vi.mock('../../../src/services/placePhotoCache', () => ({
get: (placeId: string) => mockCacheGet(placeId),
getErrored: (placeId: string) => mockCacheGetErrored(placeId),
put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution),
markError: vi.fn(),
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
serveFilePath: vi.fn(() => null),
}));
import {
parseOpeningHours,
buildOsmDetails,
@@ -46,6 +65,19 @@ afterEach(() => {
mockDbRun.mockReset();
mockCheckSsrf.mockReset();
mockCheckSsrf.mockResolvedValue({ allowed: true });
mockCacheGet.mockReset();
mockCacheGet.mockReturnValue(null);
mockCacheGetErrored.mockReset();
mockCacheGetErrored.mockReturnValue(false);
mockCachePut.mockReset();
mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
filePath: `/tmp/${placeId}.jpg`,
attribution,
}));
mockCacheGetInFlight.mockReset();
mockCacheGetInFlight.mockReturnValue(undefined);
mockCacheSetInFlight.mockReset();
});
// ── parseOpeningHours ─────────────────────────────────────────────────────────
@@ -995,11 +1027,9 @@ describe('getPlaceDetails (fetch stubbed)', () => {
expect(place.rating_count).toBe(200000);
expect(place.open_now).toBe(true);
expect(place.source).toBe('google');
expect(place.reviews).toHaveLength(1);
expect(place.reviews[0].author).toBe('John');
expect(place.reviews[0].rating).toBe(5);
expect(place.reviews[0].text).toBe('Amazing!');
expect(place.reviews[0].photo).toBe('https://photo.url');
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
expect(place.reviews).toHaveLength(0);
expect(place.summary).toBeNull();
});
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
@@ -1016,8 +1046,10 @@ describe('getPlaceDetails (fetch stubbed)', () => {
});
});
it('MAPS-041d: maps reviews with optional fields absent to null', async () => {
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
// expanded=1 cache miss → return undefined
mockDbGet.mockReturnValueOnce(undefined);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
@@ -1028,8 +1060,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
],
}),
}));
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'ChIJ456');
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
const result = await getPlaceDetailsExpanded(1, 'ChIJ456');
const review = (result.place as any).reviews[0];
expect(review.author).toBeNull();
expect(review.rating).toBeNull();
@@ -1104,8 +1136,10 @@ describe('getPlaceDetails (fetch stubbed)', () => {
expect((result.place as any).open_now).toBe(false);
});
it('MAPS-041g: truncates reviews to first 5 entries', async () => {
it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
// expanded=1 cache miss
mockDbGet.mockReturnValueOnce(undefined);
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
authorAttribution: { displayName: `User${i}` },
rating: 4,
@@ -1116,8 +1150,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
ok: true,
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
}));
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'ChIJMany');
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
const result = await getPlaceDetailsExpanded(1, 'ChIJMany');
expect((result.place as any).reviews).toHaveLength(5);
});
});
@@ -1125,16 +1159,26 @@ describe('getPlaceDetails (fetch stubbed)', () => {
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
describe('getPlacePhoto (fetch stubbed)', () => {
it('MAPS-042: returns Wikimedia photo for coordinate-based lookup (no API key)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
}),
}));
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
vi.stubGlobal('fetch', vi.fn()
// First call: Wikimedia Commons API
.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
}),
})
// Second call: fetch Wikimedia image bytes
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(100),
})
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const result = await getPlacePhoto(999, 'coords:48.8,2.3', 48.8, 2.3, 'Eiffel Tower');
expect(result.photoUrl).toBe('https://wiki.org/photo.jpg');
const placeId = 'coords:48.8,2.3';
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => {
@@ -1146,37 +1190,28 @@ describe('getPlacePhoto (fetch stubbed)', () => {
await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 });
});
it('MAPS-043b: returns cached photo when cache entry is fresh and valid', async () => {
// First call populates cache; second call should use cache without fetching
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/cached.jpg' } } } },
}),
}));
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const uniqueId = `coords:cache-test-${Date.now()}`;
const first = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => {
const placeId = `coords:cache-test-${Date.now()}`;
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
mockCacheGet.mockReturnValue({
photoUrl: cachedUrl,
filePath: `/tmp/${placeId}.jpg`,
attribution: null,
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const second = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
expect(second.photoUrl).toBe(first.photoUrl);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
expect(result.photoUrl).toBe(cachedUrl);
expect(fetchMock).not.toHaveBeenCalled();
});
it('MAPS-043c: throws 404 from cache when cached entry is an error', async () => {
// Seed the cache with an error entry by triggering a no-result Wikimedia call
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ query: { pages: {} } }),
}));
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const errorId = `coords:error-cache-${Date.now()}`;
// First call causes error to be cached
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
// Second call should throw directly from cache (no fetch)
it('MAPS-043c: throws 404 from error cache without making a network request', async () => {
mockCacheGetErrored.mockReturnValue(true);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const errorId = `coords:error-cache-${Date.now()}`;
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -1194,7 +1229,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 });
});
it('MAPS-044: returns photo via Google path when API key present and photos exist', async () => {
it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
const fetchMock = vi.fn()
// First call: get place details (with photos)
@@ -1204,17 +1239,18 @@ describe('getPlacePhoto (fetch stubbed)', () => {
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
}),
})
// Second call: get media URL
// Second call: fetch image bytes
.mockResolvedValueOnce({
ok: true,
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/photo.jpg' }),
arrayBuffer: async () => new ArrayBuffer(200),
});
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const uniqueId = `ChIJABC-${Date.now()}`;
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/photo.jpg');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
expect(result.attribution).toBe('Photographer');
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => {
@@ -1240,7 +1276,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
});
it('MAPS-044d: throws 404 when media endpoint returns no photoUri', async () => {
it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
const fetchMock = vi.fn()
.mockResolvedValueOnce({
@@ -1250,8 +1286,9 @@ describe('getPlacePhoto (fetch stubbed)', () => {
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}), // no photoUri
ok: false,
status: 403,
arrayBuffer: async () => new ArrayBuffer(0),
});
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
@@ -1259,7 +1296,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
});
it('MAPS-044e: returns photo with null attribution when authorAttributions is empty', async () => {
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
const fetchMock = vi.fn()
.mockResolvedValueOnce({
@@ -1270,28 +1307,34 @@ describe('getPlacePhoto (fetch stubbed)', () => {
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/noattr.jpg' }),
arrayBuffer: async () => new ArrayBuffer(150),
});
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const noAttrId = `ChIJNoAttr-${Date.now()}`;
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/noattr.jpg');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
expect(result.attribution).toBeNull();
});
it('MAPS-044f: uses Wikimedia when API key present but placeId is coords: prefix', async () => {
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
}),
}));
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
}),
})
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(120),
})
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
// Use a unique placeId to avoid hitting the in-memory cache from other tests
const uniqueId = `coords:44f-test-${Date.now()}`;
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
expect(result.photoUrl).toBe('https://wiki.org/coords-photo.jpg');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
expect(mockCachePut).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
@@ -45,4 +46,21 @@ describe('registry integrity', () => {
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
}
});
it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => {
for (const n of SYSTEM_NOTICES) {
if (n.minVersion !== undefined) {
expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull();
}
if (n.maxVersion !== undefined) {
expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull();
}
if (n.minVersion && n.maxVersion) {
expect(
semver.lte(n.minVersion, n.maxVersion),
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`
).toBe(true);
}
}
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { isNoticeVersionActive } from '../../../src/systemNotices/service.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
const base: SystemNotice = {
id: 'test-notice',
display: 'modal',
severity: 'info',
titleKey: 'k',
bodyKey: 'k',
dismissible: true,
conditions: [],
publishedAt: '2026-01-01T00:00:00Z',
};
describe('isNoticeVersionActive', () => {
it('passes when no bounds are set', () => {
expect(isNoticeVersionActive(base, '3.5.0')).toBe(true);
});
it('passes when app version equals minVersion (inclusive)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0')).toBe(true);
});
it('fails when app version is below minVersion', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '2.9.9')).toBe(false);
});
it('fails when app version equals maxVersion (exclusive upper bound)', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.0')).toBe(false);
});
it('fails when app version exceeds maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.1')).toBe(false);
});
it('passes when app version is just below maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '2.9.9')).toBe(true);
});
it('passes when app version is inside [minVersion, maxVersion)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0', maxVersion: '3.9.9' }, '3.5.2')).toBe(true);
});
it('treats prerelease app version as base release (semver.coerce)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0-pre.42')).toBe(true);
});
it('skips notice and returns false when minVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, minVersion: 'not-semver' }, '3.0.0')).toBe(false);
});
it('skips notice and returns false when maxVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: 'garbage' }, '3.0.0')).toBe(false);
});
});