diff --git a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx index c144541a5..fd4200b03 100644 --- a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx @@ -1,189 +1,236 @@ -import { useEffect, useState, useRef, useMemo } from 'react' -import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react' -import { useTranslation } from '../../i18n' -import { memoriesApi } from '../../api/client' -import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore' -import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers' -import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' -import { DatePicker } from './JourneyDetailPageDatePicker' +import { Calendar, Camera, Check, ChevronRight, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { memoriesApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { + groupPhotosByDate, + sortProviderPhotos, + type GeoPoint, +} from '../../pages/journeyDetail/JourneyDetailPage.helpers'; +import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'; +import { DatePicker } from './JourneyDetailPageDatePicker'; +import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'; -export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] } +export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }; -export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: { - provider: string - userId: number - entries: JourneyEntry[] - trips: JourneyTrip[] - existingAssetIds: Set - onClose: () => void - onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise - initialDate?: string - contextLocation?: (GeoPoint & { name?: string }) | null - initialEntryId?: number | null - embedded?: boolean +export function ProviderPicker({ + provider, + userId, + entries, + trips, + existingAssetIds, + onClose, + onAdd, + initialDate, + contextLocation, + initialEntryId, + embedded = false, +}: { + provider: string; + userId: number; + entries: JourneyEntry[]; + trips: JourneyTrip[]; + existingAssetIds: Set; + onClose: () => void; + onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise; + initialDate?: string; + contextLocation?: (GeoPoint & { name?: string }) | null; + initialEntryId?: number | null; + embedded?: boolean; }) { - const { t } = useTranslation() - const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip') - const [photos, setPhotos] = useState([]) - const [albums, setAlbums] = useState>([]) - const [selectedAlbum, setSelectedAlbum] = useState(null) - const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState(undefined) - const [loading, setLoading] = useState(false) - const [loadingMore, setLoadingMore] = useState(false) - const [hasMore, setHasMore] = useState(false) - const [searchPage, setSearchPage] = useState(1) - const [searchFrom, setSearchFrom] = useState('') - const [searchTo, setSearchTo] = useState('') - const [selected, setSelected] = useState>(new Map()) - const [customFrom, setCustomFrom] = useState('') - const [customTo, setCustomTo] = useState('') - const [targetEntryId, setTargetEntryId] = useState(initialEntryId ?? null) - const [addToOpen, setAddToOpen] = useState(false) - const abortRef = useRef(null) - const gridRef = useRef(null) + const { t } = useTranslation(); + const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip'); + const [photos, setPhotos] = useState([]); + const [albums, setAlbums] = useState< + Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }> + >([]); + const [selectedAlbum, setSelectedAlbum] = useState(null); + const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState(undefined); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [searchPage, setSearchPage] = useState(1); + const [searchFrom, setSearchFrom] = useState(''); + const [searchTo, setSearchTo] = useState(''); + const [selected, setSelected] = useState>( + new Map() + ); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [targetEntryId, setTargetEntryId] = useState(initialEntryId ?? null); + const [addToOpen, setAddToOpen] = useState(false); + const abortRef = useRef(null); + const gridRef = useRef(null); // compute trip range const tripRange = useMemo(() => { - let from = '', to = '' + let from = '', + to = ''; for (const t of trips) { - if (t.start_date && (!from || t.start_date < from)) from = t.start_date - if (t.end_date && (!to || t.end_date > to)) to = t.end_date + if (t.start_date && (!from || t.start_date < from)) from = t.start_date; + if (t.end_date && (!to || t.end_date > to)) to = t.end_date; } - return { from, to } - }, [trips]) + return { from, to }; + }, [trips]); const cancelPending = () => { - if (abortRef.current) { abortRef.current.abort() } - abortRef.current = new AbortController() - return abortRef.current.signal - } + if (abortRef.current) { + abortRef.current.abort(); + } + abortRef.current = new AbortController(); + return abortRef.current.signal; + }; const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => { - const signal = cancelPending() - if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) } - setSearchFrom(from) - setSearchTo(to) - setSearchPage(page) + const signal = cancelPending(); + if (page === 1) { + setLoading(true); + setPhotos([]); + } else { + setLoadingMore(true); + } + setSearchFrom(from); + setSearchTo(to); + setSearchPage(page); try { - const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal) - const assets = data.assets || [] - setPhotos(prev => append ? [...prev, ...assets] : assets) - setHasMore(!!data.hasMore) + const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal); + const assets = data.assets || []; + setPhotos((prev) => (append ? [...prev, ...assets] : assets)); + setHasMore(!!data.hasMore); } catch { // A cancelled request is about to be replaced by the next one, so leave // its paging state alone. - if (!signal.aborted) setHasMore(false) + if (!signal.aborted) setHasMore(false); } - if (!signal.aborted) { setLoading(false); setLoadingMore(false) } - } + if (!signal.aborted) { + setLoading(false); + setLoadingMore(false); + } + }; const loadMorePhotos = () => { - if (loadingMore || !hasMore) return - searchPhotos(searchFrom, searchTo, searchPage + 1, true) - } + if (loadingMore || !hasMore) return; + searchPhotos(searchFrom, searchTo, searchPage + 1, true); + }; const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => { - const signal = cancelPending() - setLoading(true) - setPhotos([]) - setHasMore(false) + const signal = cancelPending(); + setLoading(true); + setPhotos([]); + setHasMore(false); try { - setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || []) - } catch { /* ignore */ } - if (!signal.aborted) setLoading(false) - } + setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || []); + } catch { + /* ignore */ + } + if (!signal.aborted) setLoading(false); + }; const loadAlbums = async () => { try { - setAlbums((await memoriesApi.albums(provider)).albums || []) + setAlbums((await memoriesApi.albums(provider)).albums || []); } catch {} - } + }; // load on mount / filter change useEffect(() => { if (filter === 'day' && initialDate) { - searchPhotos(initialDate, initialDate) + searchPhotos(initialDate, initialDate); } else if (filter === 'trip' && tripRange.from && tripRange.to) { - searchPhotos(tripRange.from, tripRange.to) + searchPhotos(tripRange.from, tripRange.to); } else if (filter === 'all') { - searchPhotos('', '') + searchPhotos('', ''); } else if (filter === 'album' && albums.length === 0) { - loadAlbums() + loadAlbums(); } - }, [filter]) + }, [filter]); const handleCustomSearch = () => { - if (customFrom && customTo) searchPhotos(customFrom, customTo) - } + if (customFrom && customTo) searchPhotos(customFrom, customTo); + }; const sortedPhotos = useMemo( () => sortProviderPhotos(photos, contextLocation), - [photos, contextLocation?.lat, contextLocation?.lng], - ) + [photos, contextLocation?.lat, contextLocation?.lng] + ); const toggleAsset = (id: string) => { - setSelected(prev => { - const next = new Map(prev) + setSelected((prev) => { + const next = new Map(prev); if (next.has(id)) { - next.delete(id) + next.delete(id); } else { - const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType - next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType }) + const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType; + next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType }); } - return next - }) - } + return next; + }); + }; const targetLabel = targetEntryId - ? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') - : t('journey.picker.newGallery') + ? entries.find((e) => e.id === targetEntryId)?.title || + entries.find((e) => e.id === targetEntryId)?.entry_date || + t('journey.stats.entries') + : t('journey.picker.newGallery'); return (
{ if (!embedded && e.target === e.currentTarget) e.preventDefault() }} + onTouchMove={(e) => { + if (!embedded && e.target === e.currentTarget) e.preventDefault(); + }} >
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > - {/* Header */} - {!embedded &&
-

- {provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider} -

- -
} + {!embedded && ( +
+

+ {provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider} +

+ +
+ )} {/* Filter bar */} -
+
{/* Tabs */} -
+
{[ ...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []), { id: 'trip' as const, label: t('journey.picker.tripPeriod') }, { id: 'custom' as const, label: t('journey.picker.dateRange') }, { id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') }, { id: 'album' as const, label: t('journey.picker.albums') }, - ].map(f => ( - ))}
{/* Filter content — always visible row */} - {(!embedded || filter !== 'day') &&
- {filter === 'day' && initialDate && ( -
- - - {new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} - - {contextLocation?.name && · near {contextLocation.name}} -
- )} - {filter === 'trip' && ( -
- {tripRange.from && tripRange.to ? ( - <> - - - {new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - - — - - {new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} - - - ({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days) - - - ) : ( - {t('journey.trips.noTripsLinkedSettings')} - )} -
- )} + {(!embedded || filter !== 'day') && ( +
+ {filter === 'day' && initialDate && ( +
+ + + {new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + {contextLocation?.name && · near {contextLocation.name}} +
+ )} + {filter === 'trip' && ( +
+ {tripRange.from && tripRange.to ? ( + <> + + + {new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })} + + — + + {new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + ( + {Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + + 1}{' '} + days) + + + ) : ( + {t('journey.trips.noTripsLinkedSettings')} + )} +
+ )} - {filter === 'custom' && ( -
-
- — -
- -
- )} - - {filter === 'album' && ( -
- {albums.map((a: any) => ( - - ))} - {albums.length === 0 && !loading && {t('journey.picker.noAlbums')}} -
- )} -
} +
+ )} + + {filter === 'album' && ( +
+ {albums.map((a: any) => ( + + ))} + {albums.length === 0 && !loading && ( + {t('journey.picker.noAlbums')} + )} +
+ )} +
+ )}
{/* Add-to entry selector */} - {!embedded &&
-
- {t('journey.picker.addTo')} - - {addToOpen && ( - <> -
setAddToOpen(false)} /> -
- - {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && ( -
- )} - {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => ( - + {addToOpen && ( + <> +
setAddToOpen(false)} /> +
+ - ))} -
- - )} + {entries.filter( + (e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]' + ).length > 0 &&
} + {entries + .filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]') + .map((e) => ( + + ))} +
+ + )} +
-
} + )} {/* Select all bar — sticky above grid */} - {!loading && sortedPhotos.length > 0 && (() => { - const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id)) - const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) - if (selectable.length === 0) return null - return ( -
- -
- ) - })()} + {!loading && + sortedPhotos.length > 0 && + (() => { + const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id)); + const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)); + if (selectable.length === 0) return null; + return ( +
+ +
+ ); + })()} {/* Photo grid */} -
+
{loading ? (
-
+
) : sortedPhotos.length === 0 ? ( -
+

- {filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')} + {filter === 'trip' && !tripRange.from + ? t('journey.trips.noTripsLinkedSettings') + : t('journey.detail.noPhotos')}

) : (
- {groupPhotosByDate(sortedPhotos).map(group => ( + {groupPhotosByDate(sortedPhotos).map((group) => (
{(!embedded || filter !== 'day') && ( -

+

{group.label}

)} -
+
{group.assets.map((asset: any) => { - const isSelected = selected.has(asset.id) - const alreadyAdded = existingAssetIds.has(asset.id) + const isSelected = selected.has(asset.id); + const alreadyAdded = existingAssetIds.has(asset.id); return ( - ) + ); })}
@@ -421,29 +540,40 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{/* Footer */} -
- - {selected.size} +
+ + + {selected.size} + {t('journey.picker.selected')}
- - @@ -451,5 +581,5 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
- ) + ); } diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts index 16ea5199e..11c6857da 100644 --- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts @@ -1,34 +1,37 @@ -import type { JourneyEntry } from '../../store/journeyStore' -import { GRADIENTS } from './JourneyDetailPage.constants' -import { GeoOnceError } from '../../hooks/useGeolocation' +import { GeoOnceError } from '../../hooks/useGeolocation'; +import type { JourneyEntry } from '../../store/journeyStore'; +import { GRADIENTS } from './JourneyDetailPage.constants'; // Shared by the desktop entry editor and the mobile entry sheet so a failed // one-shot position fix surfaces the same translated message everywhere. export function geoOnceErrorKey(err: unknown): string { - const code = err instanceof GeoOnceError ? err.code : 'unavailable' - return code === 'permission-denied' ? 'journey.editor.locationPermissionDenied' - : code === 'timeout' ? 'journey.editor.locationTimeout' - : code === 'insecure-context' ? 'journey.editor.locationInsecureContext' - : 'journey.editor.locationUnavailable' + const code = err instanceof GeoOnceError ? err.code : 'unavailable'; + return code === 'permission-denied' + ? 'journey.editor.locationPermissionDenied' + : code === 'timeout' + ? 'journey.editor.locationTimeout' + : code === 'insecure-context' + ? 'journey.editor.locationInsecureContext' + : 'journey.editor.locationUnavailable'; } export function pickGradient(id: number): string { - return GRADIENTS[id % GRADIENTS.length] + return GRADIENTS[id % GRADIENTS.length]; } export function groupByDate(entries: JourneyEntry[]): Map { - const groups = new Map() + const groups = new Map(); for (const e of entries) { - const d = e.entry_date - if (!groups.has(d)) groups.set(d, []) - groups.get(d)!.push(e) + const d = e.entry_date; + if (!groups.has(d)) groups.set(d, []); + groups.get(d)!.push(e); } - return groups + return groups; } export function createDraftJourneyEntry(journeyId: number, now = new Date()): JourneyEntry { - const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` - const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` + const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; return { id: 0, journey_id: journeyId, @@ -41,30 +44,30 @@ export function createDraftJourneyEntry(journeyId: number, now = new Date()): Jo photos: [], created_at: 0, updated_at: 0, - } + }; } export function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } { - const date = new Date(d + 'T00:00:00') + const date = new Date(d + 'T00:00:00'); // Pass the app's selected locale so weekday/month follow the UI language // instead of the browser's navigator.language. return { weekday: date.toLocaleDateString(locale, { weekday: 'long' }), month: date.toLocaleDateString(locale, { month: 'long' }), day: date.getDate(), - } + }; } export function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string { - return `/api/photos/${p.photo_id}/${size}` + return `/api/photos/${p.photo_id}/${size}`; } export function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] { - const map = new Map() + const map = new Map(); for (const asset of photos) { - const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__' - if (!map.has(key)) map.set(key, []) - map.get(key)!.push(asset) + const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(asset); } // Group order must not depend on the order of `photos`. Once the caller sorts by // distance to the entry, first-seen order would put the day holding the nearest @@ -72,47 +75,59 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string; // within a day is left as handed in, so the distance sort still applies there. return [...map.entries()] .sort((a, b) => { - if (a[0] === '__unknown__') return 1 - if (b[0] === '__unknown__') return -1 - return b[0].localeCompare(a[0]) + if (a[0] === '__unknown__') return 1; + if (b[0] === '__unknown__') return -1; + return b[0].localeCompare(a[0]); }) .map(([date, assets]) => ({ - date, - label: date === '__unknown__' - ? 'Unknown date' - : new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }), - assets, - })) + date, + label: + date === '__unknown__' + ? 'Unknown date' + : new Date(date + 'T00:00:00').toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }), + assets, + })); } export interface ProviderPhotoAsset { - id: string - takenAt?: string | null - lat?: number | null - lng?: number | null - [key: string]: unknown + id: string; + takenAt?: string | null; + lat?: number | null; + lng?: number | null; + [key: string]: unknown; } export interface GeoPoint { - lat: number - lng: number + lat: number; + lng: number; } export function isValidGeoPoint(point: Partial | null | undefined): point is GeoPoint { - return !!point && Number.isFinite(point.lat) && Number.isFinite(point.lng) - && point.lat >= -90 && point.lat <= 90 && point.lng >= -180 && point.lng <= 180 + return ( + !!point && + Number.isFinite(point.lat) && + Number.isFinite(point.lng) && + point.lat >= -90 && + point.lat <= 90 && + point.lng >= -180 && + point.lng <= 180 + ); } /** Return the great-circle distance in metres between two coordinates. */ export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number { - const earthRadius = 6371000 - const toRadians = (value: number) => value * Math.PI / 180 - const dLat = toRadians(b.lat - a.lat) - const dLng = toRadians(b.lng - a.lng) - const latA = toRadians(a.lat) - const latB = toRadians(b.lat) - const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2 - return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h))) + const earthRadius = 6371000; + const toRadians = (value: number) => (value * Math.PI) / 180; + const dLat = toRadians(b.lat - a.lat); + const dLng = toRadians(b.lng - a.lng); + const latA = toRadians(a.lat); + const latB = toRadians(b.lat); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2; + return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h))); } /** @@ -120,7 +135,7 @@ export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number { * Journey location. The original provider order is the stable fallback. */ export function sortProviderPhotos(photos: T[], location?: GeoPoint | null): T[] { - if (!isValidGeoPoint(location)) return photos + if (!isValidGeoPoint(location)) return photos; return photos .map((photo, index) => ({ @@ -131,10 +146,10 @@ export function sortProviderPhotos(photos: T[], lo : null, })) .sort((a, b) => { - if (a.distance === null && b.distance === null) return a.index - b.index - if (a.distance === null) return 1 - if (b.distance === null) return -1 - return a.distance - b.distance || a.index - b.index + if (a.distance === null && b.distance === null) return a.index - b.index; + if (a.distance === null) return 1; + if (b.distance === null) return -1; + return a.distance - b.distance || a.index - b.index; }) - .map(item => item.photo) + .map((item) => item.photo); }