Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.
This commit is contained in:
Maurice
2026-05-31 18:29:22 +02:00
parent 9614a5cf84
commit 80627f33fd
9 changed files with 0 additions and 1584 deletions
@@ -1,215 +0,0 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import PhotoGallery from './PhotoGallery'
vi.mock('./PhotoLightbox', () => ({
PhotoLightbox: ({ onClose, onDelete, photos, initialIndex }: any) => (
<div data-testid="lightbox" data-index={initialIndex}>
<button onClick={onClose}>close-lightbox</button>
<button onClick={() => onDelete(photos[initialIndex]?.id)}>delete-photo</button>
</div>
),
}))
vi.mock('./PhotoUpload', () => ({
PhotoUpload: ({ onClose }: any) => (
<div data-testid="photo-upload">
<button onClick={onClose}>close-upload</button>
</div>
),
}))
vi.mock('../shared/Modal', () => ({
default: ({ isOpen, children }: any) =>
isOpen ? <div data-testid="modal">{children}</div> : null,
}))
const buildPhoto = (overrides = {}) => ({
id: 1,
url: '/uploads/photo1.jpg',
caption: null,
original_name: 'photo1.jpg',
day_id: null,
place_id: null,
file_size: 102400,
created_at: '2025-01-15T12:00:00Z',
...overrides,
})
const defaultProps = {
onUpload: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined),
onUpdate: vi.fn().mockResolvedValue(undefined),
places: [],
days: [],
tripId: 1,
}
describe('PhotoGallery', () => {
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined)
defaultProps.onDelete = vi.fn().mockResolvedValue(undefined)
defaultProps.onUpdate = vi.fn().mockResolvedValue(undefined)
})
it('FE-COMP-PHOTOGALLERY-001: shows photo count in header', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
// The count paragraph renders "2 Fotos" as split text nodes
expect(screen.getByText((content, el) => el?.tagName === 'P' && el.textContent?.trim().startsWith('2'))).toBeInTheDocument()
expect(screen.getAllByText('Fotos').length).toBeGreaterThan(0)
})
it('FE-COMP-PHOTOGALLERY-002: shows empty state when no photos', () => {
render(<PhotoGallery {...defaultProps} photos={[]} />)
// noPhotos key renders some text — check the empty state container is visible
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(0)
// The empty-state button should exist
const uploadButtons = screen.getAllByRole('button')
expect(uploadButtons.length).toBeGreaterThan(0)
})
it('FE-COMP-PHOTOGALLERY-003: renders one thumbnail per photo plus one upload tile', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 }), buildPhoto({ id: 3 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(3)
// Upload tile button (with Upload icon and "add" text) is present
const buttons = screen.getAllByRole('button')
// At least the upload tile button exists alongside the header upload button
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('FE-COMP-PHOTOGALLERY-004: clicking thumbnail opens lightbox at correct index', async () => {
const user = userEvent.setup()
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
expect(thumbnails).toHaveLength(2)
await user.click(thumbnails[1] as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
})
it('FE-COMP-PHOTOGALLERY-005: closing lightbox hides it', async () => {
const user = userEvent.setup()
const photos = [buildPhoto()]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
await user.click(screen.getByText('close-lightbox'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-006: upload button opens upload modal', async () => {
const user = userEvent.setup()
render(<PhotoGallery {...defaultProps} photos={[]} />)
// The header upload button
const uploadButtons = screen.getAllByRole('button')
// First button with Upload icon in header
await user.click(uploadButtons[0])
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByTestId('photo-upload')).toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-007: day filter dropdown shows all days as options', () => {
const days = [
{ id: 1, day_number: 1, date: '2025-01-10', trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
render(<PhotoGallery {...defaultProps} photos={[]} days={days} />)
const select = screen.getByRole('combobox')
const options = Array.from(select.querySelectorAll('option'))
// "All days" + 2 day options
expect(options.length).toBe(3)
})
it('FE-COMP-PHOTOGALLERY-008: filtering by day hides photos from other days', async () => {
const user = userEvent.setup()
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(1)
})
it('FE-COMP-PHOTOGALLERY-009: reset filter button appears and clears filter', async () => {
const user = userEvent.setup()
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
// Reset button should now be visible
const resetButton = screen.getByRole('button', { name: /reset/i })
expect(resetButton).toBeInTheDocument()
await user.click(resetButton)
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(2)
})
it('FE-COMP-PHOTOGALLERY-010: deleting last photo in lightbox closes lightbox', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
await user.click(screen.getByText('delete-photo'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-011: deleting a photo adjusts lightbox index when beyond bounds', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 }), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnails[1] as HTMLElement)
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
await user.click(screen.getByText('delete-photo'))
// Lightbox should still be open but at index 0
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('0')
})
})
@@ -1,217 +0,0 @@
import { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
interface PhotoGalleryProps {
photos: Photo[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (photoId: number) => Promise<void>
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
places: Place[]
days: Day[]
tripId: number
}
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t, language } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
const filteredPhotos = useMemo(() => {
return photos.filter(photo => {
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false
return true
})
}, [photos, filterDayId])
const handlePhotoClick = (photo) => {
const idx = filteredPhotos.findIndex(p => p.id === photo.id)
setLightboxIndex(idx)
}
const handleDelete = async (photoId) => {
await onDelete(photoId)
if (lightboxIndex !== null) {
const newPhotos = filteredPhotos.filter(p => p.id !== photoId)
if (newPhotos.length === 0) {
setLightboxIndex(null)
} else if (lightboxIndex >= newPhotos.length) {
setLightboxIndex(newPhotos.length - 1)
}
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, flexWrap: 'wrap' }}>
<div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
</p>
</div>
<select
value={filterDayId}
onChange={e => setFilterDayId(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">{t('photos.allDays')}</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
</option>
))}
</select>
{filterDayId && (
<button
onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
{t('common.reset')}
</button>
)}
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
>
<Upload className="w-4 h-4" />
{t('common.upload')}
</button>
</div>
{/* Gallery Grid */}
<div className="flex-1 overflow-y-auto p-4">
{filteredPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>{t('photos.noPhotos')}</p>
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</p>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
style={{ display: 'inline-flex', margin: '0 auto' }}
>
<Upload className="w-4 h-4" />
{t('common.upload')}
</button>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filteredPhotos.map(photo => (
<PhotoThumbnail
key={photo.id}
photo={photo}
days={days}
places={places}
onClick={() => handlePhotoClick(photo)}
/>
))}
{/* Upload tile */}
<button
onClick={() => setShowUpload(true)}
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
>
<Upload className="w-6 h-6" />
<span className="text-xs">{t('common.add')}</span>
</button>
</div>
)}
</div>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={filteredPhotos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onUpdate={onUpdate}
onDelete={handleDelete}
days={days}
places={places}
tripId={tripId}
/>
)}
{/* Upload Modal */}
<Modal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
title={t('common.upload')}
size="lg"
>
<PhotoUpload
tripId={tripId}
days={days}
places={places}
onUpload={async (formData) => {
await onUpload(formData)
setShowUpload(false)
}}
onClose={() => setShowUpload(false)}
/>
</Modal>
</div>
)
}
interface PhotoThumbnailProps {
photo: Photo
days: Day[]
places: Place[]
onClick: () => void
}
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="aspect-square rounded-xl overflow-hidden cursor-pointer relative group bg-gray-100"
onClick={onClick}
>
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
(e.target as HTMLImageElement).style.display = 'none'
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
}}
/>
{/* Fallback */}
<div className="hidden absolute inset-0 items-center justify-center text-gray-400 text-2xl">
🖼
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex flex-col justify-end p-2 opacity-0 group-hover:opacity-100">
{photo.caption && (
<p className="text-white text-xs font-medium truncate">{photo.caption}</p>
)}
{(day || place) && (
<p className="text-white/70 text-xs truncate">
{day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''}
</p>
)}
</div>
</div>
)
}
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -1,194 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { PhotoLightbox } from './PhotoLightbox'
const buildPhoto = (overrides = {}) => ({
id: 1,
url: '/uploads/p1.jpg',
caption: null,
original_name: 'p1.jpg',
day_id: null,
place_id: null,
file_size: 204800,
created_at: '2025-03-10T10:00:00Z',
...overrides,
})
const defaultProps = {
photos: [buildPhoto({ id: 1 }), buildPhoto({ id: 2, url: '/uploads/p2.jpg', original_name: 'p2.jpg' })],
initialIndex: 0,
onClose: vi.fn(),
onUpdate: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined),
days: [],
places: [],
tripId: 99,
}
describe('PhotoLightbox', () => {
let confirmSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
confirmSpy.mockRestore()
})
it('FE-COMP-PHOTOLIGHTBOX-001: renders the current photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const img = screen.getByRole('img', { name: /p1\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p1.jpg')
})
it('FE-COMP-PHOTOLIGHTBOX-002: shows photo counter "1 / 2"', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
it('FE-COMP-PHOTOLIGHTBOX-003: next button advances to second photo', async () => {
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// Find the ChevronRight button — it's the one after the image in the image area
const buttons = screen.getAllByRole('button')
const nextBtn = buttons.find(btn => btn.querySelector('svg') && btn.className.includes('rounded-full') && btn.className.includes('right-4'))
?? buttons.find(btn => btn.className.includes('rounded-full') && !btn.className.includes('left-4'))
// Use the button with ChevronRight — at index 0, only next button is shown
// It's within the image area, has class "rounded-full" and no left-4
const imageAreaButtons = buttons.filter(btn => btn.className.includes('rounded-full'))
expect(imageAreaButtons).toHaveLength(1) // only next at index 0
await user.click(imageAreaButtons[0])
expect(screen.getByText('2 / 2')).toBeInTheDocument()
const img = screen.getByRole('img', { name: /p2\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p2.jpg')
})
it('FE-COMP-PHOTOLIGHTBOX-004: prev button not shown at index 0', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// At index 0 only the next (ChevronRight) rounded-full button appears
const roundedButtons = screen.getAllByRole('button').filter(btn =>
btn.className.includes('rounded-full'),
)
expect(roundedButtons).toHaveLength(1)
// Confirm this single button is the next button (right-4)
expect(roundedButtons[0].className).toContain('right-4')
})
it('FE-COMP-PHOTOLIGHTBOX-005: ArrowRight keyboard event advances photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('2 / 2')).toBeInTheDocument()
})
it('FE-COMP-PHOTOLIGHTBOX-006: Escape keyboard event calls onClose', () => {
render(<PhotoLightbox {...defaultProps} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-007: clicking backdrop calls onClose', async () => {
const user = userEvent.setup()
const { container } = render(<PhotoLightbox {...defaultProps} />)
// The outer div.fixed has the onClick={onClose}. Click it directly.
const backdrop = container.firstChild as HTMLElement
await user.click(backdrop)
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-008: delete button triggers confirm and calls onDelete', async () => {
confirmSpy.mockReturnValue(true)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// The trash button has title matching delete
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).toHaveBeenCalledWith(1)
})
it('FE-COMP-PHOTOLIGHTBOX-009: delete cancelled via confirm does not call onDelete', async () => {
confirmSpy.mockReturnValue(false)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).not.toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-010: clicking caption text enters edit mode', async () => {
const user = userEvent.setup()
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Sunset view' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
// Click on the caption paragraph
const captionEl = screen.getByText('Sunset view')
await user.click(captionEl)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('Sunset view')
})
it('FE-COMP-PHOTOLIGHTBOX-011: saving caption calls onUpdate', async () => {
const user = userEvent.setup()
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Old caption' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
// Enter edit mode
await user.click(screen.getByText('Old caption'))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'New caption')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(defaultProps.onUpdate).toHaveBeenCalledWith(1, { caption: 'New caption' })
})
})
it('FE-COMP-PHOTOLIGHTBOX-012: thumbnail strip renders for multiple photos', () => {
const { container } = render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// Thumbnail strip has buttons each containing an img with alt=""
// querySelectorAll finds them regardless of ARIA role filtering
const thumbnailImgs = container.querySelectorAll('button img[alt=""]')
expect(thumbnailImgs).toHaveLength(2)
})
it('FE-COMP-PHOTOLIGHTBOX-013: day and place metadata displayed when photo has day/place', () => {
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, day_id: 1, place_id: 1 })],
days: [{ id: 1, day_number: 2, trip_id: 99, date: null, notes: null }],
places: [{ id: 1, name: 'Colosseum', trip_id: 99, lat: null, lng: null, category: null, notes: null, day_id: null, address: null, order_index: 0 }],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
expect(screen.getByText(/Tag 2/)).toBeInTheDocument()
expect(screen.getByText(/Colosseum/)).toBeInTheDocument()
})
})
@@ -1,242 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
interface PhotoLightboxProps {
photos: Photo[]
initialIndex: number
onClose: () => void
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
onDelete: (photoId: number) => Promise<void>
days: Day[]
places: Place[]
tripId: number
}
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }: PhotoLightboxProps) {
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"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
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={t('photos.addCaption')}
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">{t('photos.addCaption')}</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, locale = 'en-US') {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { 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`
}
@@ -1,158 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import type { Day, Place } from '../../types'
import { PhotoUpload } from './PhotoUpload'
beforeAll(() => {
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:mock'), writable: true })
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), writable: true })
})
const defaultProps = {
tripId: 1,
days: [{ id: 1, trip_id: 1, day_number: 1, date: null }] as Day[],
places: [{ id: 1, trip_id: 1, name: 'Eiffel Tower' }] as Place[],
onUpload: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
}
function makeFile(name = 'photo.jpg', type = 'image/jpeg') {
return new File(['(binary)'], name, { type })
}
async function uploadFiles(files: File[]) {
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await userEvent.upload(input, files)
}
/** The upload/submit button is always the last button in the DOM. */
function getSubmitButton() {
const buttons = screen.getAllByRole('button')
return buttons[buttons.length - 1]
}
describe('PhotoUpload', () => {
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined)
defaultProps.onClose = vi.fn()
})
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
render(<PhotoUpload {...defaultProps} />)
expect(screen.getByText('Drop photos here')).toBeInTheDocument()
// Upload icon rendered via lucide-react as SVG
expect(document.querySelector('svg')).toBeTruthy()
})
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
render(<PhotoUpload {...defaultProps} />)
expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
render(<PhotoUpload {...defaultProps} />)
// The upload button is the last button and should be disabled with no files
const uploadBtn = getSubmitButton()
expect(uploadBtn).toBeDisabled()
})
it('FE-COMP-PHOTOUPLOAD-004: selecting a file shows preview and reveals options', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
expect(screen.getByText('Link Day')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
const removeButtons = document.querySelectorAll('.relative.aspect-square button')
expect(removeButtons.length).toBe(2)
await userEvent.click(removeButtons[0])
expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
expect(screen.getAllByRole('img').length).toBe(1)
})
it('FE-COMP-PHOTOUPLOAD-007: upload button calls onUpload with FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
const file = makeFile()
await uploadFiles([file])
await userEvent.click(getSubmitButton())
expect(defaultProps.onUpload).toHaveBeenCalledOnce()
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData).toBeInstanceOf(FormData)
expect(formData.get('photos')).toBe(file)
})
it('FE-COMP-PHOTOUPLOAD-008: day selection adds day_id to FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
// First combobox is the day selector; select day id=1
const selects = screen.getAllByRole('combobox')
await userEvent.selectOptions(selects[0], '1')
await userEvent.click(getSubmitButton())
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData.get('day_id')).toBe('1')
})
it('FE-COMP-PHOTOUPLOAD-009: caption field adds caption to FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
await userEvent.click(getSubmitButton())
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData.get('caption')).toBe('Vacation')
})
it('FE-COMP-PHOTOUPLOAD-010: cancel button calls onClose', async () => {
render(<PhotoUpload {...defaultProps} />)
const cancelBtn = screen.getByRole('button', { name: /abbrechen|cancel/i })
await userEvent.click(cancelBtn)
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
it('FE-COMP-PHOTOUPLOAD-011: upload in progress shows spinner and disables button', async () => {
let resolveUpload!: () => void
const pendingPromise = new Promise<void>(resolve => { resolveUpload = resolve })
defaultProps.onUpload = vi.fn().mockReturnValue(pendingPromise)
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
await userEvent.click(getSubmitButton())
await waitFor(() => {
expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
})
expect(getSubmitButton()).toBeDisabled()
// Cleanup
resolveUpload()
})
})
@@ -1,205 +0,0 @@
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import type { Place, Day } from '../../types'
interface PhotoUploadProps {
tripId: number
days: Day[]
places: Place[]
onUpload: (fd: FormData) => Promise<void>
onClose: () => void
}
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
const { t } = useTranslation()
const toast = useToast()
const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('')
const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const onDrop = useCallback((acceptedFiles) => {
const withPreview = acceptedFiles.map(file =>
Object.assign(file, { preview: URL.createObjectURL(file) })
)
setFiles(prev => [...prev, ...withPreview])
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.heic'] },
maxFiles: 30,
maxSize: 10 * 1024 * 1024,
})
const removeFile = (index) => {
setFiles(prev => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
setProgress(0)
try {
const formData = new FormData()
files.forEach(file => formData.append('photos', file))
if (dayId) formData.append('day_id', dayId)
if (placeId) formData.append('place_id', placeId)
if (caption) formData.append('caption', caption)
await onUpload(formData)
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
} catch (err: unknown) {
console.error('Upload failed:', err)
toast.error(t('common.error'))
} finally {
setUploading(false)
setProgress(0)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-4">
{/* Dropzone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-slate-900 bg-slate-50'
: 'border-gray-300 hover:border-slate-400 hover:bg-gray-50'
}`}
>
<input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? (
<p className="text-slate-700 font-medium">{t('photos.dropHere')}</p>
) : (
<>
<p className="text-gray-600 font-medium">{t('photos.dropHereActive')}</p>
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
<p className="text-gray-400 text-xs mt-2">{t('photos.fileTypeHint')}</p>
</>
)}
</div>
{/* Preview grid */}
{files.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => (
<div key={idx} className="relative aspect-square group">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover rounded-lg"
/>
<button
onClick={() => removeFile(idx)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity truncate">
{formatSize(file.size)}
</div>
</div>
))}
</div>
</div>
)}
{/* Options */}
{files.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkDay')}</label>
<select
value={dayId}
onChange={e => setDayId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">{t('photos.noDay')}</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>{t('photos.dayLabel', { number: day.day_number })}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkPlace')}</label>
<select
value={placeId}
onChange={e => setPlaceId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">{t('photos.noPlace')}</option>
{(places || []).map(place => (
<option key={place.id} value={place.id}>{place.name}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.captionForAll')}</label>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder={t('photos.captionPlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
</div>
</div>
)}
{/* Upload progress */}
{uploading && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">{t('common.uploading')}</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-slate-900 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={files.length === 0 || uploading}
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
<Upload className="w-4 h-4" />
{uploading ? t('common.uploading') : t('photos.uploadN', { n: files.length })}
</button>
</div>
</div>
)
}
-230
View File
@@ -1,230 +0,0 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, act } from '../../tests/helpers/render';
import { Route, Routes } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import PhotosPage from './PhotosPage';
import type { Photo } from '../types';
vi.mock('../components/Photos/PhotoGallery', () => ({
default: ({ photos }: { photos: Photo[]; onUpload: unknown; onDelete: unknown; onUpdate: unknown; places: unknown[]; days: unknown[]; tripId: unknown }) =>
React.createElement('div', { 'data-testid': 'photo-gallery' }, `${photos.length} photos`),
}));
vi.mock('../components/Layout/Navbar', () => ({
default: ({ tripTitle }: { tripTitle?: string }) =>
React.createElement('nav', { 'data-testid': 'navbar' }, tripTitle),
}));
function buildPhoto(overrides: Partial<Photo> = {}): Photo {
return {
id: 1,
trip_id: 1,
url: '/uploads/photos/photo1.jpg',
original_name: 'photo1.jpg',
mime_type: 'image/jpeg',
file_size: 12345,
caption: null,
place_id: null,
day_id: null,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
function renderPhotosPage(tripId: number | string = 1) {
return render(
<Routes>
<Route path="/trips/:id/photos" element={<PhotosPage />} />
</Routes>,
{ initialEntries: [`/trips/${tripId}/photos`] },
);
}
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useTripStore, {
photos: [],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
});
describe('PhotosPage', () => {
describe('FE-PAGE-PHOTOS-001: Loading spinner shown while data fetches', () => {
it('shows a spinner while data is loading', async () => {
server.use(
http.get('/api/trips/:id', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
const trip = buildTrip({ id: 1 });
return HttpResponse.json({ trip });
}),
);
renderPhotosPage(1);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
describe('FE-PAGE-PHOTOS-002: Trip name in Navbar after load', () => {
it('passes the trip name to Navbar after data loads', async () => {
const trip = buildTrip({ id: 1, name: 'Venice Trip' });
server.use(
http.get('/api/trips/:id', () => HttpResponse.json({ trip })),
);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('navbar')).toHaveTextContent('Venice Trip');
});
});
});
describe('FE-PAGE-PHOTOS-003: PhotoGallery renders after load', () => {
it('renders the PhotoGallery after data loads', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PHOTOS-004: Photo count shown in header', () => {
it('shows the correct photo count in the header', async () => {
const photo = buildPhoto({ id: 1, trip_id: 1 });
seedStore(useTripStore, {
photos: [photo],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByText(/1 photos for/i)).toBeInTheDocument();
});
});
describe('FE-PAGE-PHOTOS-005: Back link navigates to trip planner', () => {
it('back link points to the trip planner page', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
const backLink = screen.getByRole('link', { name: /back to planning/i });
expect(backLink.getAttribute('href')).toContain('/trips/1');
});
});
describe('FE-PAGE-PHOTOS-006: loadPhotos called with trip ID on mount', () => {
it('calls tripStore.loadPhotos with the trip ID from the URL', async () => {
const mockLoadPhotos = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
photos: [],
loadPhotos: mockLoadPhotos,
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(mockLoadPhotos).toHaveBeenCalledWith('1');
});
});
});
describe('FE-PAGE-PHOTOS-007: Navigation to /dashboard on fetch error', () => {
it('navigates to /dashboard when trip fetch fails', async () => {
server.use(
http.get('/api/trips/:id', () =>
HttpResponse.json({ error: 'Not found' }, { status: 404 }),
),
);
render(
<Routes>
<Route path="/trips/:id/photos" element={<PhotosPage />} />
<Route path="/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>,
{ initialEntries: ['/trips/1/photos'] },
);
await waitFor(() => {
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PHOTOS-008: Photos sync from tripStore to local state', () => {
it('PhotoGallery re-renders when store photos change', async () => {
seedStore(useTripStore, {
photos: [],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('0 photos');
act(() => {
useTripStore.setState({ photos: [buildPhoto({ id: 99 })] } as any);
});
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('1 photos');
});
});
});
describe('FE-PAGE-PHOTOS-009: Empty photo list renders gallery with 0 photos', () => {
it('renders PhotoGallery with 0 photos when photos array is empty', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('0 photos');
});
});
describe('FE-PAGE-PHOTOS-010: Page heading present', () => {
it('renders the "Fotos" heading', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: /photos/i })).toBeInTheDocument();
});
});
});
-57
View File
@@ -1,57 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import { PageSpinner } from '../components/shared/Spinner'
import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import { usePhotos } from './photos/usePhotos'
export default function PhotosPage(): React.ReactElement {
const { t } = useTranslation()
// Page = wiring container: trip/days/places load, photo sync + handlers live in the hook.
const { tripId, navigate, trip, days, places, photos, isLoading, handleUpload, handleDelete, handleUpdate } = usePhotos()
if (isLoading) {
return (
<PageSpinner
wrapperClassName="min-h-screen flex items-center justify-center bg-slate-50"
className="w-10 h-10 border-4 border-slate-200 border-t-slate-700"
/>
)
}
return (
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.name, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Link
to={`/trips/${tripId}`}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="w-4 h-4" />
{t('common.backToPlanning')}
</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('photos.title')}</h1>
<p className="text-gray-500 text-sm">{t('photos.subtitle', { count: photos.length, trip: trip?.name })}</p>
</div>
</div>
<PhotoGallery
photos={photos}
onUpload={handleUpload}
onDelete={handleDelete}
onUpdate={handleUpdate}
places={places}
days={days}
tripId={tripId}
/>
</div>
</PageShell>
)
}
-66
View File
@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../../store/tripStore'
import { tripsApi, daysApi, placesApi } from '../../api/client'
import type { Trip, Day, Place, Photo } from '../../types'
/**
* Photos page data hook — owns the trip/days/places load, the photo sync from
* the trip store and the upload/delete/update handlers. PhotosPage is a pure
* wiring container. Behaviour is identical to the previous in-component logic.
*/
export function usePhotos() {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [days, setDays] = useState<Day[]>([])
const [places, setPlaces] = useState<Place[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
])
setTrip(tripData.trip)
setDays(daysData.days)
setPlaces(placesData.places)
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
// Sync photos from store
useEffect(() => {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId: number): Promise<void> => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
await tripStore.updatePhoto(tripId, photoId, data)
}
return { tripId, navigate, trip, days, places, photos, isLoading, handleUpload, handleDelete, handleUpdate }
}