mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
47671d52e0
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.
231 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|