Files
TREK/client/src/components/Photos/PhotoLightbox.jsx
Maurice 0497032ed7 v2.5.7: Reservation overhaul, Day Detail Panel, i18n, paste support, auto dark mode
BREAKING: Reservations have been completely rebuilt. Existing place-level
reservations are no longer used. All reservations must be re-created via
the Bookings tab. Your trips, places, and other data are unaffected.

Reservation System (rebuilt from scratch):
- Reservations now link to specific day assignments instead of places
- Same place on different days can have independent reservations
- New assignment picker in booking modal (grouped by day, searchable)
- Removed day/place dropdowns from booking form
- Reservation badges in day plan sidebar with type-specific icons
- Reservation details in place inspector (only for selected assignment)
- Reservation summary in day detail panel

Day Detail Panel (new):
- Opens on day click in the sidebar
- Detailed weather: hourly forecast, precipitation, wind, sunrise/sunset
- Historical climate averages for dates beyond 16 days
- Accommodation management with check-in/check-out, confirmation number
- Hotel assignment across multiple days with day range picker
- Reservation overview for the day

Places:
- Places can now be assigned to the same day multiple times
- Start time + end time fields (replaces single time field)
- Map badges show multiple position numbers (e.g. "1 · 4")
- Route optimization fixed for duplicate places
- File attachments during place editing (not just creation)
- Cover image upload during trip creation (not just editing)
- Paste support (Ctrl+V) for images in trip, place, and file forms

Internationalization:
- 200+ hardcoded German strings translated to i18n (EN + DE)
- Server error messages in English
- Category seeds in English for new installations
- All planner, register, photo, packing components translated

UI/UX:
- Auto dark mode (follows system preference, configurable in settings)
- Navbar toggle switches light/dark (overrides auto)
- Sidebar minimize buttons z-index fixed
- Transport mode selector removed from day plan
- CustomSelect supports grouped headers (isHeader option)
- Optimistic updates for day notes (instant feedback)
- Booking cards redesigned with type-colored headers and structured details

Weather:
- Wind speed in mph when using Fahrenheit setting
- Weather description language matches app language

Admin:
- Weather info panel replaces OpenWeatherMap key input
- "Recommended" badge styling updated
2026-03-24 20:10:45 +01:00

230 lines
7.6 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false)
const [caption, setCaption] = useState('')
const [isSaving, setIsSaving] = useState(false)
const photo = photos[index]
useEffect(() => {
setIndex(initialIndex || 0)
}, [initialIndex])
useEffect(() => {
if (photo) setCaption(photo.caption || '')
}, [photo])
const prev = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
setEditCaption(false)
}, [])
const next = useCallback(() => {
setIndex(i => Math.min(photos.length - 1, i + 1))
setEditCaption(false)
}, [photos.length])
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onClose, prev, next])
const handleSaveCaption = async () => {
setIsSaving(true)
try {
await onUpdate(photo.id, { caption })
setEditCaption(false)
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Foto löschen?')) return
await onDelete(photo.id)
if (photos.length <= 1) {
onClose()
} else {
setIndex(i => Math.min(i, photos.length - 2))
}
}
if (!photo) return null
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={onClose}
>
{/* Main area */}
<div
className="relative flex flex-col w-full h-full max-w-5xl mx-auto"
onClick={e => e.stopPropagation()}
>
{/* Top bar */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<div className="text-white/60 text-sm">
{index + 1} / {photos.length}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Image area */}
<div className="flex-1 flex items-center justify-center relative min-h-0 px-16">
{/* Prev button */}
{index > 0 && (
<button
onClick={prev}
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="max-h-full max-w-full object-contain rounded-lg select-none"
draggable={false}
/>
{/* Next button */}
{index < photos.length - 1 && (
<button
onClick={next}
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
</div>
{/* Bottom info */}
<div className="flex-shrink-0 p-4">
{/* Caption */}
<div className="flex items-center gap-2 mb-2">
{editCaption ? (
<>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
placeholder="Beschriftung hinzufügen..."
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
autoFocus
/>
<button
onClick={handleSaveCaption}
disabled={isSaving}
className="p-1.5 bg-slate-900 text-white rounded-lg hover:bg-slate-700"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => { setEditCaption(false); setCaption(photo.caption || '') }}
className="p-1.5 text-white/60 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<p
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)}
>
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
</p>
<button
onClick={() => setEditCaption(true)}
className="p-1.5 text-white/40 hover:text-white/70"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-white/40 text-xs">
<span>{photo.original_name}</span>
{photo.created_at && (
<span>{formatDate(photo.created_at)}</span>
)}
{day && <span>📅 Tag {day.day_number}</span>}
{place && <span>📍 {place.name}</span>}
{photo.file_size && <span>{formatSize(photo.file_size)}</span>}
</div>
</div>
{/* Thumbnail strip */}
{photos.length > 1 && (
<div className="flex-shrink-0 px-4 pb-4">
<div className="flex gap-1.5 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button
key={p.id}
onClick={() => { setIndex(i); setEditCaption(false) }}
className={`flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden transition-all ${
i === index
? 'ring-2 ring-white scale-105'
: 'opacity-50 hover:opacity-75'
}`}
>
<img
src={p.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
} catch { return '' }
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}