feat: Journey addon — travel journal with entries, photos, public sharing & PDF export

- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91
- Trip-to-Journey sync engine with skeleton entries and photo sync
- Full CRUD API for journeys, entries, photos with Immich/Synology integration
- Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons)
- Journey frontpage with hero card, stats and trip suggestions
- Public share links with token-based access and photo proxy
- PDF photo book export (Polarsteps-inspired)
- Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design
- BottomNav profile sheet with settings/admin/logout
- DayPlan mobile inline place picker
- TripFormModal members management
- Vacay calendar trip date indicator dots
- Fix contributor photo access (403) for journey Immich/Synology photos
- Trip deletion cleanup for journey skeleton entries
- i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
This commit is contained in:
Maurice
2026-04-11 19:01:34 +02:00
parent 0df90086bf
commit 13956804c2
56 changed files with 10843 additions and 332 deletions
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
dayId: number
places: Place[]
assignments: AssignmentsMap
onAssign?: (placeId: number, dayId: number) => void
onAddNew?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
// Find places not assigned to this day
const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id))
const available = places.filter(p => !assignedToDay.has(p.id))
const filtered = search.trim()
? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
: available
return (
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
{!open ? (
<button
onClick={e => { e.stopPropagation(); setOpen(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderRadius: 12,
border: '1.5px dashed var(--border-primary)',
background: 'transparent', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={14} />
Add Place
</button>
) : (
<div style={{ borderRadius: 14, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 6 }}>
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('dayplan.mobile.searchPlaces')}
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)' }}
/>
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div style={{ padding: '16px 12px', textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>
{available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
</div>
)}
{filtered.slice(0, 20).map(p => (
<button
key={p.id}
onClick={() => {
onAssign?.(p.id, dayId)
setOpen(false)
setSearch('')
}}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', border: 'none', background: 'transparent',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
}}
>
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
{onAddNew && (
<button
onClick={() => { onAddNew(); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderTop: '1px solid var(--border-faint)',
background: 'transparent', border: 'none', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={13} />
Create new place
</button>
)}
</div>
)}
</div>
)
}
interface DayPlanSidebarProps {
tripId: number
trip: Trip
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
reservations?: Reservation[]
onAddReservation: () => void
onNavigateToFiles?: () => void
onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles,
onExpandedDaysChange,
pushUndo,
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
</div>
)}
{/* Mobile: Add Place from list */}
<MobileAddPlaceButton
dayId={day.id}
places={places}
assignments={assignments}
onAssign={onAssignToDay}
onAddNew={onAddPlace}
/>
</div>
)}
</div>