Files
k-photos/k-photos-frontend/components/image-viewer.tsx
Gabriel Kaszewski 957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
Backend:
- user roles (DB + JWT + first-user-is-admin)
- volume-aware file resolver (multi-volume asset serving)
- directory scanner uses volume URI directly
- date-summary endpoint (capture date from EXIF)
- timeline ordered by capture date
- list endpoints: volumes, plugins, pipelines, library paths
- delete endpoints: volumes, library paths
- configurable upload body limit (MAX_UPLOAD_BYTES)

Frontend:
- auth: login/register, token refresh, role-based admin gate
- timeline: date-grouped grid, infinite scroll, date scrubber
- image viewer: fullscreen zoom/pan/pinch, metadata sidebar
- upload: drag-drop, sequential upload, progress tracking
- albums: create, add/remove photos, asset picker dialog
- admin: storage (import library), jobs (pagination, error details),
  plugins (list + toggle), pipelines, sidecars, duplicates
- multi-select mode with add-to-album action
- TanStack Query for all data fetching
2026-06-01 01:35:43 +02:00

184 lines
5.3 KiB
TypeScript

"use client"
import { useEffect, useState, useCallback, useRef } from "react"
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"
import type { AssetResponse } from "@/lib/types"
import { getTokens } from "@/lib/auth"
import { MetadataSidebar } from "./metadata-sidebar"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
XIcon,
ZoomInIcon,
ZoomOutIcon,
Maximize2Icon,
InfoIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
interface ImageViewerProps {
assets: AssetResponse[]
initialIndex: number
onClose: () => void
}
export function ImageViewer({ assets, initialIndex, onClose }: ImageViewerProps) {
const [index, setIndex] = useState(initialIndex)
const [src, setSrc] = useState<string | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(false)
const prevBlobRef = useRef<string | null>(null)
const asset = assets[index]
const hasPrev = index > 0
const hasNext = index < assets.length - 1
useEffect(() => {
if (prevBlobRef.current) URL.revokeObjectURL(prevBlobRef.current)
setSrc(null)
const { access } = getTokens()
fetch(`/api/v1/assets/${asset.id}/file`, {
headers: access ? { Authorization: `Bearer ${access}` } : {},
})
.then((r) => (r.ok ? r.blob() : Promise.reject()))
.then((blob) => {
const url = URL.createObjectURL(blob)
prevBlobRef.current = url
setSrc(url)
})
.catch(() => {})
return () => {
if (prevBlobRef.current) {
URL.revokeObjectURL(prevBlobRef.current)
prevBlobRef.current = null
}
}
}, [asset.id])
const goPrev = useCallback(() => {
if (hasPrev) setIndex((i) => i - 1)
}, [hasPrev])
const goNext = useCallback(() => {
if (hasNext) setIndex((i) => i + 1)
}, [hasNext])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose()
if (e.key === "ArrowLeft") goPrev()
if (e.key === "ArrowRight") goNext()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [onClose, goPrev, goNext])
return (
<div className="fixed inset-0 z-50 flex bg-black">
{/* Main image area */}
<div className="relative flex flex-1 items-center justify-center">
<TransformWrapper
key={asset.id}
initialScale={1}
minScale={0.5}
maxScale={10}
centerOnInit
wheel={{ step: 0.1 }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
{/* Toolbar */}
<div className="absolute top-0 right-0 left-0 z-10 flex items-center justify-between p-3">
<div />
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => zoomIn()} icon={<ZoomInIcon />} />
<ToolbarButton onClick={() => zoomOut()} icon={<ZoomOutIcon />} />
<ToolbarButton onClick={() => resetTransform()} icon={<Maximize2Icon />} />
<ToolbarButton
onClick={() => setSidebarOpen(!sidebarOpen)}
icon={<InfoIcon />}
active={sidebarOpen}
/>
<ToolbarButton onClick={onClose} icon={<XIcon />} />
</div>
</div>
{/* Image */}
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{src ? (
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
draggable={false}
/>
) : (
<Skeleton className="h-96 w-96 rounded-lg" />
)}
</TransformComponent>
</>
)}
</TransformWrapper>
{/* Navigation arrows */}
{hasPrev && (
<button
onClick={goPrev}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
)}
{hasNext && (
<button
onClick={goNext}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
)}
</div>
{/* Metadata sidebar */}
{sidebarOpen && (
<MetadataSidebar
asset={asset}
onClose={() => setSidebarOpen(false)}
/>
)}
</div>
)
}
function ToolbarButton({
onClick,
icon,
active,
}: {
onClick: () => void
icon: React.ReactNode
active?: boolean
}) {
return (
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={`h-8 w-8 text-white hover:bg-white/20 ${active ? "bg-white/20" : ""}`}
>
<span className="h-4 w-4">{icon}</span>
</Button>
)
}