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
104 lines
2.7 KiB
TypeScript
104 lines
2.7 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useCallback } from "react"
|
|
import { useTimeline } from "@/hooks/use-timeline"
|
|
import { PhotoCard } from "./photo-card"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
|
|
interface AssetPickerDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
excludeIds?: Set<string>
|
|
onConfirm: (assetIds: string[]) => void
|
|
}
|
|
|
|
export function AssetPickerDialog({
|
|
open,
|
|
onOpenChange,
|
|
excludeIds,
|
|
onConfirm,
|
|
}: AssetPickerDialogProps) {
|
|
const { assets, isLoading, hasMore, loadMore } = useTimeline()
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
|
|
const toggle = useCallback((id: string, sel: boolean) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (sel) next.add(id)
|
|
else next.delete(id)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const filtered = excludeIds
|
|
? assets.filter((a) => !excludeIds.has(a.id))
|
|
: assets
|
|
|
|
const handleConfirm = () => {
|
|
onConfirm(Array.from(selected))
|
|
setSelected(new Set())
|
|
onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!o) setSelected(new Set())
|
|
onOpenChange(o)
|
|
}}
|
|
>
|
|
<DialogContent className="flex flex-col sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Select Photos</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4 md:grid-cols-5">
|
|
{filtered.map((asset) => (
|
|
<PhotoCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
selectable
|
|
selected={selected.has(asset.id)}
|
|
onSelect={(sel) => toggle(asset.id, sel)}
|
|
/>
|
|
))}
|
|
</div>
|
|
{hasMore && (
|
|
<div className="flex justify-center py-3">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => loadMore()}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? <Spinner /> : "Load more"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
<div className="flex items-center justify-between border-t pt-3">
|
|
<span className="text-sm text-muted-foreground">
|
|
{selected.size} selected
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
disabled={selected.size === 0}
|
|
onClick={handleConfirm}
|
|
>
|
|
Add to Album
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|