feat: photo metadata panel in preview view
Extract the single-photo body of RightSidebar into a reusable PhotoInfoPanel (rating / color / flag / filename / title / notes / tags / EXIF) and mount it inside PreviewView as a toggleable right-side overlay so the user can rate, tag, and read EXIF without leaving the loupe. - New PhotoInfoPanel: self-contained, owns its own queries and mutations, takes a single photoId. darkTheme prop reserved for future use. - RightSidebar: thinned down — delegates the single-select case to PhotoInfoPanel, keeps its own slim bulk-action panel for multi-select. - PreviewView: I toggles the panel; new top-right Info button mirrors it. - useKeyboardShortcuts: gate the global I (right-sidebar toggle) to grid mode so it doesn't double-fire alongside the preview-scoped handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Info } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { PreviewImage } from './PreviewImage'
|
||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
|
||||
export function PreviewView() {
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
@@ -15,6 +16,7 @@ export function PreviewView() {
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||
|
||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||
// it up by key (which broke when the key gained the filter params).
|
||||
@@ -42,6 +44,7 @@ export function PreviewView() {
|
||||
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
||||
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||
|
||||
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
||||
// (browsers can't preload them via Image()) and skip when at the edges.
|
||||
@@ -122,33 +125,58 @@ export function PreviewView() {
|
||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
||||
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={closePreview}
|
||||
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
||||
title="Close (Esc)"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Filename + counter */}
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||
<div className="font-mono">{currentPhoto.filename}</div>
|
||||
<div className="text-text-muted">
|
||||
{safeIndex + 1} / {photos.length}
|
||||
{/* Main column — image + filmstrip */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Filename + counter */}
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||
<div className="font-mono">{currentPhoto.filename}</div>
|
||||
<div className="text-text-muted">
|
||||
{safeIndex + 1} / {photos.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-right action buttons */}
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setInfoPanelOpen((v) => !v)}
|
||||
className={
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
|
||||
(infoPanelOpen ? 'ring-2 ring-primary' : '')
|
||||
}
|
||||
title="Toggle info panel (I)"
|
||||
aria-label="Toggle info panel"
|
||||
aria-pressed={infoPanelOpen}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={closePreview}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
||||
title="Close (Esc)"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PreviewImage photo={currentPhoto} />
|
||||
|
||||
<PreviewFilmstrip
|
||||
photos={photos}
|
||||
currentIndex={safeIndex}
|
||||
onSelect={setActivePhoto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PreviewImage photo={currentPhoto} />
|
||||
|
||||
<PreviewFilmstrip
|
||||
photos={photos}
|
||||
currentIndex={safeIndex}
|
||||
onSelect={setActivePhoto}
|
||||
/>
|
||||
{/* Right info panel — slides in/out, mirrors the grid right sidebar
|
||||
* but lives inside the preview overlay so it isn't covered by it. */}
|
||||
{infoPanelOpen && (
|
||||
<aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface">
|
||||
<PhotoInfoPanel photoId={currentPhoto.id} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user