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:
2026-04-08 13:01:25 +02:00
parent 1c428dda8b
commit 3a03a56db2
4 changed files with 922 additions and 753 deletions

View File

@@ -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>
)
}