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
184 lines
5.3 KiB
TypeScript
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>
|
|
)
|
|
}
|