Files
TREK/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
T
Maurice 47671d52e0 Decompose the remaining God Components into hooks, helpers and sub-components
FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.
2026-05-31 20:07:17 +02:00

231 lines
9.7 KiB
TypeScript

import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
userId: number
trips: JourneyTrip[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
}) {
const { t } = useTranslation()
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
const galleryUploading = galleryProgress !== null
const toast = useToast()
// check which providers are enabled AND connected for the current user
useEffect(() => {
(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabledProviders = (addonsData.addons || []).filter(
(a: any) => a.type === 'photo_provider' && a.enabled
)
const connected: { id: string; name: string }[] = []
for (const p of enabledProviders) {
try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
if (res.ok) {
const status = await res.json()
if (status.connected) connected.push({ id: p.id, name: p.name })
}
} catch {}
}
setAvailableProviders(connected)
} catch {}
})()
}, [])
const allPhotos = gallery
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
const browseProvider = (provider: string) => {
setPickerProvider(provider)
setShowPicker(true)
}
const galleryFileRef = useRef<HTMLInputElement>(null)
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
setGalleryProgress({ done: 0, total: files.length })
try {
const normalized = await normalizeImageFiles(files)
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
})
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
} else {
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
}
onRefresh()
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
} finally {
setGalleryProgress(null)
}
e.target.value = ''
}
const handleDeletePhoto = async (galleryPhotoId: number) => {
const store = useJourneyStore.getState()
if (!store.current) return
// Optimistic update — remove from gallery and all entry photo lists
useJourneyStore.setState({
current: {
...store.current,
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== galleryPhotoId),
})),
},
})
try {
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch {
toast.error(t('common.error'))
onRefresh()
}
}
return (
<div>
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => galleryFileRef.current?.click()}
disabled={galleryUploading}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
>
{galleryUploading ? (
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
) : (
<><Plus size={12} /> {t('common.upload')}</>
)}
</button>
{availableProviders.map(p => (
<button
key={p.id}
onClick={() => browseProvider(p.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<Image size={12} />
{p.name}
</button>
))}
</div>
</div>
{allPhotos.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Image size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">{t('journey.detail.noPhotos')}</p>
<p className="text-[12px] text-zinc-500 mt-1">{t('journey.detail.noPhotosHint')}</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map((photo, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{/* Delete button */}
<button
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X size={12} />
</button>
{photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
</span>
</div>
)}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
)}
{/* Provider Photo Picker Modal */}
{showPicker && (
<ProviderPicker
provider={pickerProvider!}
userId={userId}
entries={entriesWithContent}
trips={trips}
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => {
let added = 0
let anyFailed = false
for (const group of groups) {
try {
if (entryId) {
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0
}
} catch {
anyFailed = true
}
}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
} else if (anyFailed) {
toast.error(t('common.error'))
}
setShowPicker(false)
}}
/>
)}
</div>
)
}