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
87 lines
2.6 KiB
TypeScript
87 lines
2.6 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import type { AssetResponse } from "@/lib/types"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { getTokens } from "@/lib/auth"
|
|
import { ImageIcon } from "lucide-react"
|
|
|
|
interface PhotoCardProps {
|
|
asset: AssetResponse
|
|
selected?: boolean
|
|
selectable?: boolean
|
|
onClick?: () => void
|
|
onSelect?: (selected: boolean) => void
|
|
}
|
|
|
|
export function PhotoCard({
|
|
asset,
|
|
selected,
|
|
selectable,
|
|
onClick,
|
|
onSelect,
|
|
}: PhotoCardProps) {
|
|
const [src, setSrc] = useState<string | null>(null)
|
|
const [failed, setFailed] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let revoke: string | null = null
|
|
setFailed(false)
|
|
const { access } = getTokens()
|
|
const headers: HeadersInit = access
|
|
? { Authorization: `Bearer ${access}` }
|
|
: {}
|
|
fetch(`/api/v1/assets/${asset.id}/derivatives/thumbnail_square`, {
|
|
headers,
|
|
})
|
|
.then((r) => (r.ok ? r.blob() : Promise.reject()))
|
|
.then((blob) => {
|
|
revoke = URL.createObjectURL(blob)
|
|
setSrc(revoke)
|
|
})
|
|
.catch(() => setFailed(true))
|
|
return () => {
|
|
if (revoke) URL.revokeObjectURL(revoke)
|
|
}
|
|
}, [asset.id])
|
|
|
|
return (
|
|
<div
|
|
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-md bg-muted ${selected ? "ring-2 ring-primary ring-offset-2" : ""}`}
|
|
onClick={selectable ? () => onSelect?.(!selected) : onClick}
|
|
>
|
|
{src ? (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
) : failed ? (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<ImageIcon className="h-8 w-8 text-muted-foreground/40" />
|
|
</div>
|
|
) : (
|
|
<Skeleton className="h-full w-full" />
|
|
)}
|
|
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
|
|
{selectable && (
|
|
<div className="absolute top-1.5 left-1.5">
|
|
<Checkbox
|
|
checked={selected}
|
|
onCheckedChange={(c) => onSelect?.(c === true)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="border-white bg-black/30"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 translate-y-full p-2 transition-transform group-hover:translate-y-0">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{asset.asset_type}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|