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
This commit is contained in:
86
k-photos-frontend/components/photo-card.tsx
Normal file
86
k-photos-frontend/components/photo-card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user