From 80627f33fd4ec067511b6441aaa1540e5ce39728 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 31 May 2026 18:29:22 +0200 Subject: [PATCH] Remove the unrouted photos page and its dead photo components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Photos/PhotoGallery.test.tsx | 215 ---------------- client/src/components/Photos/PhotoGallery.tsx | 217 ---------------- .../components/Photos/PhotoLightbox.test.tsx | 194 -------------- .../src/components/Photos/PhotoLightbox.tsx | 242 ------------------ .../components/Photos/PhotoUpload.test.tsx | 158 ------------ client/src/components/Photos/PhotoUpload.tsx | 205 --------------- client/src/pages/PhotosPage.test.tsx | 230 ----------------- client/src/pages/PhotosPage.tsx | 57 ----- client/src/pages/photos/usePhotos.ts | 66 ----- 9 files changed, 1584 deletions(-) delete mode 100644 client/src/components/Photos/PhotoGallery.test.tsx delete mode 100644 client/src/components/Photos/PhotoGallery.tsx delete mode 100644 client/src/components/Photos/PhotoLightbox.test.tsx delete mode 100644 client/src/components/Photos/PhotoLightbox.tsx delete mode 100644 client/src/components/Photos/PhotoUpload.test.tsx delete mode 100644 client/src/components/Photos/PhotoUpload.tsx delete mode 100644 client/src/pages/PhotosPage.test.tsx delete mode 100644 client/src/pages/PhotosPage.tsx delete mode 100644 client/src/pages/photos/usePhotos.ts diff --git a/client/src/components/Photos/PhotoGallery.test.tsx b/client/src/components/Photos/PhotoGallery.test.tsx deleted file mode 100644 index 70af3bb0..00000000 --- a/client/src/components/Photos/PhotoGallery.test.tsx +++ /dev/null @@ -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) => ( -
- - -
- ), -})) - -vi.mock('./PhotoUpload', () => ({ - PhotoUpload: ({ onClose }: any) => ( -
- -
- ), -})) - -vi.mock('../shared/Modal', () => ({ - default: ({ isOpen, children }: any) => - isOpen ?
{children}
: 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() - // 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() - // 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() - 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() - - 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() - - 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() - - // 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() - - 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() - - 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() - - 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() - - 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() - - 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') - }) -}) diff --git a/client/src/components/Photos/PhotoGallery.tsx b/client/src/components/Photos/PhotoGallery.tsx deleted file mode 100644 index 4e7a90da..00000000 --- a/client/src/components/Photos/PhotoGallery.tsx +++ /dev/null @@ -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 - onDelete: (photoId: number) => Promise - onUpdate: (photoId: number, data: Partial) => Promise - 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 ( -
- {/* Header */} -
-
-

Fotos

-

- {photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'} -

-
- - - - {filterDayId && ( - - )} - - -
- - {/* Gallery Grid */} -
- {filteredPhotos.length === 0 ? ( -
- -

{t('photos.noPhotos')}

-

{t('photos.uploadHint')}

- -
- ) : ( -
- {filteredPhotos.map(photo => ( - handlePhotoClick(photo)} - /> - ))} - - {/* Upload tile */} - -
- )} -
- - {/* Lightbox */} - {lightboxIndex !== null && ( - setLightboxIndex(null)} - onUpdate={onUpdate} - onDelete={handleDelete} - days={days} - places={places} - tripId={tripId} - /> - )} - - {/* Upload Modal */} - setShowUpload(false)} - title={t('common.upload')} - size="lg" - > - { - await onUpload(formData) - setShowUpload(false) - }} - onClose={() => setShowUpload(false)} - /> - -
- ) -} - -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 ( -
- {photo.caption { - (e.target as HTMLImageElement).style.display = 'none' - const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex' - }} - /> - - {/* Fallback */} -
- 🖼️ -
- - {/* Hover overlay */} -
- {photo.caption && ( -

{photo.caption}

- )} - {(day || place) && ( -

- {day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''} -

- )} -
-
- ) -} - -function formatDate(dateStr, locale) { - if (!dateStr) return '' - return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) -} diff --git a/client/src/components/Photos/PhotoLightbox.test.tsx b/client/src/components/Photos/PhotoLightbox.test.tsx deleted file mode 100644 index 30b0be78..00000000 --- a/client/src/components/Photos/PhotoLightbox.test.tsx +++ /dev/null @@ -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 - - beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) - }) - - afterEach(() => { - confirmSpy.mockRestore() - }) - - it('FE-COMP-PHOTOLIGHTBOX-001: renders the current photo', () => { - render() - 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() - expect(screen.getByText('1 / 2')).toBeInTheDocument() - }) - - it('FE-COMP-PHOTOLIGHTBOX-003: next button advances to second photo', async () => { - const user = userEvent.setup() - render() - - // 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() - // 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() - 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() - 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() - // 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() - - // 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() - - 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() - - // 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() - - // 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() - - // 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() - - expect(screen.getByText(/Tag 2/)).toBeInTheDocument() - expect(screen.getByText(/Colosseum/)).toBeInTheDocument() - }) -}) diff --git a/client/src/components/Photos/PhotoLightbox.tsx b/client/src/components/Photos/PhotoLightbox.tsx deleted file mode 100644 index bb90170a..00000000 --- a/client/src/components/Photos/PhotoLightbox.tsx +++ /dev/null @@ -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) => Promise - onDelete: (photoId: number) => Promise - 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 ( -
- {/* Main area */} -
e.stopPropagation()} - > - {/* Top bar */} -
-
- {index + 1} / {photos.length} -
-
- - -
-
- - {/* Image area */} -
- {/* Prev button */} - {index > 0 && ( - - )} - - {photo.caption - - {/* Next button */} - {index < photos.length - 1 && ( - - )} -
- - {/* Bottom info */} -
- {/* Caption */} -
- {editCaption ? ( - <> - 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 - /> - - - - ) : ( - <> -

setEditCaption(true)} - > - {photo.caption || {t('photos.addCaption')}} -

- - - )} -
- - {/* Metadata */} -
- {photo.original_name} - {photo.created_at && ( - {formatDate(photo.created_at)} - )} - {day && 📅 Tag {day.day_number}} - {place && 📍 {place.name}} - {photo.file_size && {formatSize(photo.file_size)}} -
-
- - {/* Thumbnail strip */} - {photos.length > 1 && ( -
-
- {photos.map((p, i) => ( - - ))} -
-
- )} -
-
- ) -} - -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` -} diff --git a/client/src/components/Photos/PhotoUpload.test.tsx b/client/src/components/Photos/PhotoUpload.test.tsx deleted file mode 100644 index bd9de2e2..00000000 --- a/client/src/components/Photos/PhotoUpload.test.tsx +++ /dev/null @@ -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() - 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() - 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() - // 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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(resolve => { resolveUpload = resolve }) - defaultProps.onUpload = vi.fn().mockReturnValue(pendingPromise) - - render() - await uploadFiles([makeFile()]) - - await userEvent.click(getSubmitButton()) - - await waitFor(() => { - expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0) - }) - - expect(getSubmitButton()).toBeDisabled() - - // Cleanup - resolveUpload() - }) -}) diff --git a/client/src/components/Photos/PhotoUpload.tsx b/client/src/components/Photos/PhotoUpload.tsx deleted file mode 100644 index c9fbbc4e..00000000 --- a/client/src/components/Photos/PhotoUpload.tsx +++ /dev/null @@ -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 - 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 ( -
- {/* Dropzone */} -
- - - {isDragActive ? ( -

{t('photos.dropHere')}

- ) : ( - <> -

{t('photos.dropHereActive')}

-

{t('photos.clickToSelect')}

-

{t('photos.fileTypeHint')}

- - )} -
- - {/* Preview grid */} - {files.length > 0 && ( -
-

{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}

-
- {files.map((file, idx) => ( -
- {file.name} - -
- {formatSize(file.size)} -
-
- ))} -
-
- )} - - {/* Options */} - {files.length > 0 && ( -
-
- - -
-
- - -
-
- - 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" - /> -
-
- )} - - {/* Upload progress */} - {uploading && ( -
-
-
- {t('common.uploading')} -
-
-
-
-
- )} - - {/* Actions */} -
- - -
-
- ) -} diff --git a/client/src/pages/PhotosPage.test.tsx b/client/src/pages/PhotosPage.test.tsx deleted file mode 100644 index 0274c452..00000000 --- a/client/src/pages/PhotosPage.test.tsx +++ /dev/null @@ -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 { - 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( - - } /> - , - { 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( - - } /> - Dashboard
} /> - , - { 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(); - }); - }); -}); diff --git a/client/src/pages/PhotosPage.tsx b/client/src/pages/PhotosPage.tsx deleted file mode 100644 index 93b0a32f..00000000 --- a/client/src/pages/PhotosPage.tsx +++ /dev/null @@ -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 ( - - ) - } - - return ( - navigate(`/trips/${tripId}`) }}> -
- {/* Header */} -
- - - {t('common.backToPlanning')} - -
- -
-
-

{t('photos.title')}

-

{t('photos.subtitle', { count: photos.length, trip: trip?.name })}

-
-
- - -
-
- ) -} diff --git a/client/src/pages/photos/usePhotos.ts b/client/src/pages/photos/usePhotos.ts deleted file mode 100644 index 15847d7c..00000000 --- a/client/src/pages/photos/usePhotos.ts +++ /dev/null @@ -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(null) - const [days, setDays] = useState([]) - const [places, setPlaces] = useState([]) - const [photos, setPhotos] = useState([]) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - loadData() - }, [tripId]) - - const loadData = async (): Promise => { - 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 => { - await tripStore.addPhoto(tripId, formData) - } - - const handleDelete = async (photoId: number): Promise => { - await tripStore.deletePhoto(tripId, photoId) - } - - const handleUpdate = async (photoId: number, data: Record): Promise => { - await tripStore.updatePhoto(tripId, photoId, data) - } - - return { tripId, navigate, trip, days, places, photos, isLoading, handleUpload, handleDelete, handleUpdate } -}