Files
k-photos/k-photos-frontend/components/photo-card.tsx
Gabriel Kaszewski 957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
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
2026-06-01 01:35:43 +02:00

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