mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Merge branch 'mauriceboe:dev' into dev
This commit is contained in:
@@ -356,9 +356,13 @@ 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),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
|
||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
const items = grouped.get(oldName) || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
|
||||
@@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||
if (builder) return builder(p.photo_id)
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
@@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0])}
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
@@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail')}
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
|
||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16, overflow: 'auto',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
position: 'sticky', bottom: 0, background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||
<Github size={13} />
|
||||
|
||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
||||
id: 400,
|
||||
title: 'Flight to Rome',
|
||||
type: 'flight',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T14:30:00',
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||
|
||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const totalCost = Object.values(assignments || {})
|
||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
|
||||
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (!startId || startId === endId) return 'single'
|
||||
if (dayId === startId) return 'start'
|
||||
if (dayId === endId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||
const phase = pdfGetSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId == null) return false
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find(d => d.id === startId)
|
||||
const endDay = sorted.find(d => d.id === endId)
|
||||
const thisDay = sorted.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||
}
|
||||
return startId === dayId
|
||||
})
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Reservations for this day (hotel rendered via accommodations block)
|
||||
const dayReservations = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id)
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
|
||||
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const fmtTime = (v) => {
|
||||
if (!v) return v
|
||||
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
return formatTime12(v, is12h)
|
||||
}
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const collapsed = collapsedProp
|
||||
const toggleCollapse = () => onToggleCollapse?.()
|
||||
|
||||
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
{res.reservation_end_time && ` – ${(() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
})()}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
|
||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
} else if (form.reservation_end_time && form.reservation_time) {
|
||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
|
||||
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
{(() => {
|
||||
const endDatePart = r.reservation_end_time
|
||||
? r.reservation_end_time.includes('T')
|
||||
? r.reservation_end_time.split('T')[0]
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||
? r.reservation_end_time
|
||||
: null
|
||||
: null
|
||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||
})() && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -75,6 +77,8 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
price: '',
|
||||
budget_category: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -94,6 +98,13 @@ interface TransportModalProps {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId])
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
await onSave(payload)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
@@ -422,6 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
|
||||
},
|
||||
],
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
gallery: [
|
||||
{
|
||||
id: 100,
|
||||
journey_id: 1,
|
||||
photo_id: 100,
|
||||
provider: 'local',
|
||||
file_path: 'photos/test.jpg',
|
||||
asset_id: null,
|
||||
owner_id: null,
|
||||
thumbnail_path: null,
|
||||
caption: 'Colosseum',
|
||||
sort_order: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
shared: 1,
|
||||
created_at: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
|
||||
it('renders the empty gallery state when journey has no photos', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
// Override with entries that have no photos
|
||||
// Override with entries that have no photos and empty gallery
|
||||
const emptyEntry = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [],
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [emptyEntry],
|
||||
gallery: [],
|
||||
stats: { entries: 1, photos: 0, places: 1 },
|
||||
});
|
||||
|
||||
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
|
||||
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
|
||||
const dateOverlay = document.querySelector('[class*="opacity-0"]');
|
||||
expect(dateOverlay).toBeTruthy();
|
||||
// Gallery photos render in a grid; each photo has a group container
|
||||
const photos = document.querySelectorAll('[class*="aspect-square"]');
|
||||
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
|
||||
setupDefaultHandlers({
|
||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
gallery: [{
|
||||
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
|
||||
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
|
||||
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||
}],
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
|
||||
setupDefaultHandlers({
|
||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
gallery: [{
|
||||
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
|
||||
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
|
||||
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||
}],
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
||||
it('uploading files in gallery creates an entry and uploads photos', async () => {
|
||||
it('uploading files in gallery calls gallery upload API', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let createCalled = false;
|
||||
let uploadCalled = false;
|
||||
|
||||
server.use(
|
||||
http.post('/api/journeys/1/entries', () => {
|
||||
createCalled = true;
|
||||
return HttpResponse.json({
|
||||
id: 99, journey_id: 1, author_id: 1, type: 'entry',
|
||||
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
|
||||
location_lat: null, location_lng: null, mood: null, weather: null,
|
||||
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
|
||||
entry_time: null, photos: [], created_at: now, updated_at: now,
|
||||
});
|
||||
}),
|
||||
http.post('/api/journeys/entries/99/photos', () => {
|
||||
http.post('/api/journeys/1/gallery/photos', () => {
|
||||
uploadCalled = true;
|
||||
return HttpResponse.json([]);
|
||||
return HttpResponse.json({ photos: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
|
||||
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
||||
await user.upload(fileInput, testFile);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCalled).toBe(true);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(uploadCalled).toBe(true);
|
||||
});
|
||||
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
http.delete('/api/journeys/photos/100', () => {
|
||||
http.delete('/api/journeys/1/gallery/100', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
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 type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin
|
||||
}
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ export default function JourneyDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
||||
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
@@ -458,7 +458,7 @@ export default function JourneyDetailPage() {
|
||||
className={
|
||||
isMobile
|
||||
? ''
|
||||
: 'flex-1 overflow-y-auto journey-feed-scroll'
|
||||
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
|
||||
}
|
||||
>
|
||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() {
|
||||
>
|
||||
<GalleryView
|
||||
entries={current.entries}
|
||||
gallery={current.gallery || []}
|
||||
journeyId={current.id}
|
||||
userId={useAuthStore.getState().user?.id || 0}
|
||||
trips={current.trips}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
</div>
|
||||
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() {
|
||||
entry={editingEntry}
|
||||
journeyId={current.id}
|
||||
tripDates={tripDates}
|
||||
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
|
||||
galleryPhotos={current.gallery || []}
|
||||
onClose={() => setEditingEntry(null)}
|
||||
onSave={async (data) => {
|
||||
let entryId = editingEntry.id
|
||||
@@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
|
||||
// ── Gallery View ──────────────────────────────────────────────────────────
|
||||
|
||||
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
userId: number
|
||||
trips: JourneyTrip[]
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -1009,19 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
||||
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
|
||||
for (const e of entries) {
|
||||
for (const p of e.photos) {
|
||||
const existing = seenPhotoIds.get(p.photo_id)
|
||||
if (existing === undefined) {
|
||||
seenPhotoIds.set(p.photo_id, allPhotos.length)
|
||||
allPhotos.push({ photo: p, entry: e })
|
||||
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
|
||||
allPhotos[existing] = { photo: p, entry: e }
|
||||
}
|
||||
}
|
||||
}
|
||||
const allPhotos = gallery
|
||||
|
||||
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||
|
||||
@@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
// find existing "Gallery" entry or create one. The stored title is the
|
||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
||||
// do not send a translated label here.
|
||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||
let entryId = galleryEntry?.id
|
||||
if (!entryId) {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
entryId = entry.id
|
||||
}
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
await journeyApi.uploadPhotos(entryId, formData)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
onRefresh()
|
||||
} catch {
|
||||
@@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDeletePhoto = async (photoId: number) => {
|
||||
const handleDeletePhoto = async (galleryPhotoId: number) => {
|
||||
const store = useJourneyStore.getState()
|
||||
if (!store.current) return
|
||||
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
|
||||
if (!target) return
|
||||
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
|
||||
|
||||
// Optimistic update — remove every row with this photo_id
|
||||
const updated = {
|
||||
...store.current,
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.photo_id !== target.photo_id),
|
||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
||||
}
|
||||
useJourneyStore.setState({ current: updated })
|
||||
// Optimistic update — remove from gallery and all entry photo lists
|
||||
useJourneyStore.setState({
|
||||
current: {
|
||||
...store.current,
|
||||
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.id !== galleryPhotoId),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
|
||||
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
onRefresh()
|
||||
@@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map(({ photo, entry }, i) => (
|
||||
{allPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
@@ -1165,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
||||
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
userId={userId}
|
||||
entries={entriesWithContent}
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
targetId = entry.id
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
if (entryId) {
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} else {
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (added > 0) {
|
||||
@@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
tripDates: Set<string>
|
||||
galleryPhotos: JourneyPhoto[]
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
@@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
@@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
|
||||
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
|
||||
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
@@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
await journeyApi.deletePhoto(p.id)
|
||||
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
||||
if (entry.id > 0) {
|
||||
// unlink from entry; gallery row is preserved
|
||||
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
|
||||
} else {
|
||||
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
|
||||
}
|
||||
}}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
|
||||
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Journey/MobileMapTimeline', () => ({
|
||||
default: ({ onEntryClick }: any) => (
|
||||
<div data-testid="mobile-map-timeline">
|
||||
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
|
||||
Open Entry
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockIsMobile = { value: false };
|
||||
vi.mock('../hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => mockIsMobile.value,
|
||||
}));
|
||||
|
||||
import JourneyPublicPage from './JourneyPublicPage';
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
@@ -106,6 +121,9 @@ const mockJourneyData = {
|
||||
share_gallery: true,
|
||||
share_map: true,
|
||||
},
|
||||
gallery: [
|
||||
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
|
||||
],
|
||||
stats: {
|
||||
entries: 2,
|
||||
photos: 1,
|
||||
@@ -136,6 +154,7 @@ function setup404() {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
mockIsMobile.value = false;
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
@@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
gallery: [
|
||||
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
|
||||
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
|
||||
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
|
||||
],
|
||||
stats: { entries: 1, photos: 3, places: 0 },
|
||||
};
|
||||
|
||||
@@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => {
|
||||
expect(statsContainer!.textContent).toContain('7');
|
||||
});
|
||||
|
||||
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
|
||||
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
|
||||
mockIsMobile.value = true;
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
|
||||
expect(mapBtn).toBeUndefined();
|
||||
});
|
||||
|
||||
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
|
||||
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockIsMobile.value = true;
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
|
||||
const openBtn = screen.getByText('Open Entry');
|
||||
await user.click(openBtn);
|
||||
|
||||
// MobileEntryView should slide in with the entry title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// FE-PAGE-PUBLICJOURNEY-016
|
||||
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||
@@ -44,6 +45,17 @@ interface PublicPhoto {
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
interface PublicGalleryPhoto {
|
||||
id: number
|
||||
journey_id: number
|
||||
photo_id: number
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
file_path?: string | null
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
@@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||
}
|
||||
|
||||
@@ -96,6 +108,7 @@ export default function JourneyPublicPage() {
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
|
||||
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
|
||||
|
||||
const handleMarkerClick = useCallback((entryId: string) => {
|
||||
setActiveEntryId(entryId)
|
||||
@@ -113,21 +126,19 @@ export default function JourneyPublicPage() {
|
||||
}, [token])
|
||||
|
||||
const entries = (data?.entries || []) as PublicEntry[]
|
||||
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
|
||||
const perms = data?.permissions || {}
|
||||
const journey = data?.journey || {}
|
||||
const stats = data?.stats || {}
|
||||
|
||||
const timelineEntries = useMemo(
|
||||
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
||||
[entries],
|
||||
)
|
||||
const timelineEntries = useMemo(() => entries, [entries])
|
||||
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
|
||||
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||
const mapEntries = useMemo(
|
||||
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
||||
[timelineEntries],
|
||||
)
|
||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
||||
const allPhotos = gallery
|
||||
|
||||
// Map entries with day color/label for colored markers.
|
||||
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
|
||||
@@ -189,7 +200,7 @@ export default function JourneyPublicPage() {
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||
|
||||
// Shared timeline renderer used in both layout modes
|
||||
@@ -306,7 +317,7 @@ export default function JourneyPublicPage() {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
|
||||
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||
{photos.length !== 1 && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
||||
@@ -402,11 +413,11 @@ export default function JourneyPublicPage() {
|
||||
// Shared gallery renderer
|
||||
const renderGallery = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
{allPhotos.map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
</div>
|
||||
@@ -437,7 +448,7 @@ export default function JourneyPublicPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* Hero */}
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
|
||||
{journey.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() {
|
||||
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||
{/* Left: feed */}
|
||||
<div className="flex-1 min-w-0 px-8 py-6">
|
||||
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
|
||||
{renderTabs(availableViews)}
|
||||
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||
@@ -563,7 +574,7 @@ export default function JourneyPublicPage() {
|
||||
mapEntries={sidebarMapItems as any}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
onEntryClick={(entry) => setViewingEntry(entry as any)}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||
/>
|
||||
@@ -607,6 +618,26 @@ export default function JourneyPublicPage() {
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile entry detail view (public share) */}
|
||||
{viewingEntry && (
|
||||
<MobileEntryView
|
||||
entry={viewingEntry as any}
|
||||
readOnly
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={(photos, idx) => setLightbox({
|
||||
photos: photos.map(p => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p as any, token!, 'original'),
|
||||
caption: (p as any).caption ?? null,
|
||||
})),
|
||||
index: idx,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripActions.loadReservations(tripId)
|
||||
if (tripId) {
|
||||
tripActions.loadReservations(tripId)
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
@@ -1106,7 +1109,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); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
? <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} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} 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} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
: <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)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([202, 201, 203]);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||
);
|
||||
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([211, 212]);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
|
||||
height?: number | null
|
||||
}
|
||||
|
||||
export interface GalleryPhoto {
|
||||
id: number
|
||||
journey_id: number
|
||||
photo_id: number
|
||||
caption?: string | null
|
||||
shared: number
|
||||
sort_order: number
|
||||
created_at: number
|
||||
// Joined from trek_photos for display
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
file_path?: string | null
|
||||
thumbnail_path?: string | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
trip_id: number
|
||||
added_at: number
|
||||
@@ -79,6 +97,7 @@ export interface JourneyContributor {
|
||||
|
||||
export interface JourneyDetail extends Journey {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
stats: { entries: number; photos: number; places: number }
|
||||
@@ -103,6 +122,9 @@ interface JourneyState {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
|
||||
clear: () => void
|
||||
@@ -201,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
)
|
||||
entries.sort((a, b) => {
|
||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||
const atime = a.entry_time || ''
|
||||
const btime = b.entry_time || ''
|
||||
if (atime !== btime) return atime.localeCompare(btime)
|
||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
return a.id - b.id
|
||||
})
|
||||
return { current: { ...s.current, entries } }
|
||||
})
|
||||
@@ -228,12 +248,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
},
|
||||
|
||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||
})
|
||||
return photos
|
||||
},
|
||||
|
||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
|
||||
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
|
||||
entries: s.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
deletePhoto: async (photoId) => {
|
||||
await journeyApi.deletePhoto(photoId)
|
||||
set(s => {
|
||||
|
||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||
}
|
||||
|
||||
// navigator.standalone is true only on iOS when running as an
|
||||
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||
function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* (including text/html and image/svg+xml which can execute script) are forced
|
||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||
*
|
||||
* Falls back to a download trigger if the popup is blocked.
|
||||
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||
* window feature returns null per spec, which previously made the popup-block
|
||||
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||
* the new tab is opened by the browser's normal link-handling path, and no
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
||||
if (win) {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
} else {
|
||||
// Popup blocked — fall back to download
|
||||
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||
if (isIosStandalone()) {
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
return
|
||||
}
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||
// clean up the DOM node and revoke the URL.
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user