feat(journey): photo provider buttons beside upload in the gallery header

This commit is contained in:
Maurice
2026-09-01 14:42:04 +02:00
committed by Maurice
parent 9f6cd3dc6d
commit a96bebc8bb
4 changed files with 71 additions and 40 deletions
@@ -1,7 +1,7 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act } from 'react'
import { act, useRef, useState } from 'react'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
@@ -73,20 +73,41 @@ const entries: JourneyEntry[] = [
},
]
/** Mirrors the page wiring: the gallery reports its connected providers up,
and the host renders one button per provider next to Upload. */
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
gallery: GalleryPhoto[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
onRegisterUpload?: (fn: () => void) => void
}) {
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const browseRef = useRef<((provider: string) => void) | null>(null)
return (
<>
{providers.map(p => (
<button type="button" key={p.id} onClick={() => browseRef.current?.(p.id)}>{p.name}</button>
))}
<GalleryView
entries={entries}
gallery={gallery}
journeyId={JOURNEY_ID}
userId={1}
trips={trips}
onPhotoClick={onPhotoClick}
onRefresh={onRefresh}
onRegisterUpload={onRegisterUpload}
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
/>
</>
)
}
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
const onPhotoClick = vi.fn()
const onRefresh = vi.fn()
const utils = render(
<GalleryView
entries={entries}
gallery={gallery}
journeyId={JOURNEY_ID}
userId={1}
trips={trips}
onPhotoClick={onPhotoClick}
onRefresh={onRefresh}
onRegisterUpload={onRegisterUpload}
/>,
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} />,
)
return { ...utils, onPhotoClick, onRefresh }
}
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, X, Play } from 'lucide-react'
import { RefreshCw, Camera, X, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
@@ -13,7 +13,7 @@ import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload, onRegisterProviders }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
@@ -22,16 +22,17 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
onRegisterUpload?: (fn: () => void) => void
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => 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
// check which providers are enabled AND connected for the current user, then
// hand them up: the page header renders the provider buttons next to Upload
useEffect(() => {
(async () => {
try {
@@ -46,7 +47,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch { }
}
setAvailableProviders(connected)
onRegisterProviders?.(connected, browseProvider)
} catch { }
})()
}, [])
@@ -126,23 +127,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<div>
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
{/* Header — the provider buttons live in the page header next to Upload */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2">
{availableProviders.map(p => (
<button type="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 ? (
+25 -10
View File
@@ -10,7 +10,7 @@ import EmptyState from '../components/shared/EmptyState'
import { Outlet } from 'react-router'
import {
ArrowLeft, MoreHorizontal, List, Grid, MapPin,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
@@ -44,7 +44,8 @@ function JourneyDetailPageDesktop() {
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, galleryUploadRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
@@ -346,14 +347,27 @@ function JourneyDetailPageDesktop() {
</button>
)}
{canEditEntries && view === 'gallery' && (
<button type="button"
onClick={() => galleryUploadRef.current?.()}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}
>
<Plus size={16} strokeWidth={2.4} />
{t('common.upload')}
</button>
<div className="flex items-center gap-2">
{galleryProviders.map(p => (
<button type="button"
key={p.id}
onClick={() => galleryBrowseRef.current?.(p.id)}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}
>
<Image size={16} strokeWidth={2.4} />
{p.name}
</button>
))}
<button type="button"
onClick={() => galleryUploadRef.current?.()}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}
>
<Plus size={16} strokeWidth={2.4} />
{t('common.upload')}
</button>
</div>
)}
</div>
@@ -452,6 +466,7 @@ function JourneyDetailPageDesktop() {
>
<GalleryView
onRegisterUpload={(fn) => { galleryUploadRef.current = fn }}
onRegisterProviders={(providers, browse) => { setGalleryProviders(providers); galleryBrowseRef.current = browse }}
entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id}
@@ -31,6 +31,11 @@ export function useJourneyDetail() {
const fullMapRef = useRef<JourneyMapHandle>(null)
/** The gallery hands its file picker up here, so the hero button can open it. */
const galleryUploadRef = useRef<(() => void) | null>(null)
/** Same deal for photo providers: the gallery probes which ones are
connected and reports them up, so the header can render an Immich/
Synology button next to Upload. */
const [galleryProviders, setGalleryProviders] = useState<{ id: string; name: string }[]>([])
const galleryBrowseRef = useRef<((provider: string) => void) | null>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
@@ -384,7 +389,8 @@ export function useJourneyDetail() {
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, galleryUploadRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,