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
This commit is contained in:
183
k-photos-frontend/components/image-viewer.tsx
Normal file
183
k-photos-frontend/components/image-viewer.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user