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
181 lines
5.2 KiB
TypeScript
181 lines
5.2 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
|
import type { AssetResponse } from "@/lib/types"
|
|
import type { DateGroup } from "@/lib/timeline"
|
|
import { PhotoCard } from "./photo-card"
|
|
import { ImageViewer } from "./image-viewer"
|
|
import { AddToAlbumDialog } from "./add-to-album-dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { ImagePlusIcon, XIcon, CheckSquareIcon } from "lucide-react"
|
|
|
|
interface PhotoGridProps {
|
|
groups: DateGroup[]
|
|
isLoading: boolean
|
|
hasMore: boolean
|
|
onLoadMore: () => void
|
|
onRemoveAsset?: (assetId: string) => void
|
|
}
|
|
|
|
export function PhotoGrid({
|
|
groups,
|
|
isLoading,
|
|
hasMore,
|
|
onLoadMore,
|
|
onRemoveAsset,
|
|
}: PhotoGridProps) {
|
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
|
const [selecting, setSelecting] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
const [albumDialogOpen, setAlbumDialogOpen] = useState(false)
|
|
|
|
const allAssets = useMemo(
|
|
() => groups.flatMap((g) => g.assets),
|
|
[groups],
|
|
)
|
|
|
|
const toggleSelect = useCallback((id: string, selected: boolean) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (selected) next.add(id)
|
|
else next.delete(id)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const exitSelection = useCallback(() => {
|
|
setSelecting(false)
|
|
setSelectedIds(new Set())
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const el = sentinelRef.current
|
|
if (!el) return
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) onLoadMore()
|
|
},
|
|
{ rootMargin: "200px" },
|
|
)
|
|
observer.observe(el)
|
|
return () => observer.disconnect()
|
|
}, [onLoadMore])
|
|
|
|
if (allAssets.length === 0 && !isLoading) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
|
No photos yet. Upload some to get started.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
let flatIndex = 0
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col">
|
|
{/* Selection toolbar */}
|
|
{selecting && (
|
|
<div className="sticky top-0 z-20 flex items-center gap-2 rounded-md border bg-background px-3 py-2 shadow-sm">
|
|
<span className="text-sm font-medium">
|
|
{selectedIds.size} selected
|
|
</span>
|
|
<div className="flex-1" />
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={selectedIds.size === 0}
|
|
onClick={() => setAlbumDialogOpen(true)}
|
|
>
|
|
<ImagePlusIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
Add to Album
|
|
</Button>
|
|
{onRemoveAsset && selectedIds.size > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
selectedIds.forEach((id) => onRemoveAsset(id))
|
|
exitSelection()
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="ghost" onClick={exitSelection}>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!selecting && allAssets.length > 0 && (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-xs text-muted-foreground"
|
|
onClick={() => setSelecting(true)}
|
|
>
|
|
<CheckSquareIcon className="mr-1 h-3.5 w-3.5" />
|
|
Select
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{groups.map((group) => {
|
|
const startIndex = flatIndex
|
|
flatIndex += group.assets.length
|
|
return (
|
|
<div key={group.date}>
|
|
<h2
|
|
id={`date-${group.date}`}
|
|
data-date={group.date}
|
|
className="sticky top-0 z-10 bg-background/80 py-1.5 text-sm font-medium backdrop-blur"
|
|
>
|
|
{group.label}
|
|
</h2>
|
|
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{group.assets.map((asset, j) => (
|
|
<PhotoCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
selectable={selecting}
|
|
selected={selectedIds.has(asset.id)}
|
|
onSelect={(sel) => toggleSelect(asset.id, sel)}
|
|
onClick={() => setSelectedIndex(startIndex + j)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
{hasMore && <div ref={sentinelRef} className="h-1" />}
|
|
{isLoading && (
|
|
<div className="flex justify-center py-4">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedIndex !== null && (
|
|
<ImageViewer
|
|
assets={allAssets}
|
|
initialIndex={selectedIndex}
|
|
onClose={() => setSelectedIndex(null)}
|
|
/>
|
|
)}
|
|
|
|
<AddToAlbumDialog
|
|
assetIds={Array.from(selectedIds)}
|
|
open={albumDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setAlbumDialogOpen(open)
|
|
if (!open) exitSelection()
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|