Files
k-photos/k-photos-frontend/components/photo-grid.tsx
Gabriel Kaszewski 0077caa743 feat: safe deletion, album/asset delete, trash, README update
- 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
2026-06-01 01:57:53 +02:00

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>
)
}