- volume-aware deletion: read-only volumes remove DB only, writable volumes soft-delete to trash with configurable grace period - trash page with restore, worker purge sweep (TRASH_RETENTION_DAYS) - album delete endpoint + sidebar trash icon - asset delete from timeline selection toolbar - all listing queries exclude trashed assets (deleted_at IS NULL) - timeline ordered by EXIF capture date, date-summary endpoint - README rewritten with features, setup, full env var table
196 lines
5.7 KiB
TypeScript
196 lines
5.7 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, Trash2Icon } from "lucide-react"
|
|
|
|
interface PhotoGridProps {
|
|
groups: DateGroup[]
|
|
isLoading: boolean
|
|
hasMore: boolean
|
|
onLoadMore: () => void
|
|
onRemoveAsset?: (assetId: string) => void
|
|
onDeleteAssets?: (assetIds: string[]) => void
|
|
}
|
|
|
|
export function PhotoGrid({
|
|
groups,
|
|
isLoading,
|
|
hasMore,
|
|
onLoadMore,
|
|
onRemoveAsset,
|
|
onDeleteAssets,
|
|
}: 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="secondary"
|
|
onClick={() => {
|
|
selectedIds.forEach((id) => onRemoveAsset(id))
|
|
exitSelection()
|
|
}}
|
|
>
|
|
Remove from Album
|
|
</Button>
|
|
)}
|
|
{onDeleteAssets && selectedIds.size > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
onDeleteAssets(Array.from(selectedIds))
|
|
exitSelection()
|
|
}}
|
|
>
|
|
<Trash2Icon className="mr-1.5 h-3.5 w-3.5" />
|
|
Delete
|
|
</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>
|
|
)
|
|
}
|