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:
2026-06-01 01:35:43 +02:00
parent 49f77a78b9
commit 957737ac9b
101 changed files with 4679 additions and 109 deletions

View File

@@ -0,0 +1,204 @@
"use client"
import type { AssetResponse } from "@/lib/types"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import { ChevronDownIcon, XIcon } from "lucide-react"
interface MetadataSidebarProps {
asset: AssetResponse
onClose: () => void
}
const CAMERA_KEYS = [
"Make",
"Model",
"FocalLength",
"FocalLengthIn35mmFilm",
"FNumber",
"ExposureTime",
"ISOSpeedRatings",
"ExposureMode",
"ExposureProgram",
"MeteringMode",
"Flash",
"WhiteBalanceMode",
"LightSource",
]
const GPS_KEYS = ["GPSInfo", "GPSLatitude", "GPSLongitude", "GPSAltitude"]
const HIDDEN_KEYS = [
"file_size_bytes",
"mime_type",
"ExifImageWidth",
"ExifImageHeight",
...CAMERA_KEYS,
...GPS_KEYS,
]
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatExposure(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
const num = parseInt(match[1])
const den = parseInt(match[2])
if (den === 0) return val
const result = num / den
return result < 1 ? `1/${Math.round(1 / result)}s` : `${result}s`
}
function formatFocalLength(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
return `${Math.round(parseInt(match[1]) / parseInt(match[2]))}mm`
}
function formatFNumber(val: string): string {
const match = val.match(/^(\d+)\/(\d+)/)
if (!match) return val
return `f/${(parseInt(match[1]) / parseInt(match[2])).toFixed(1)}`
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4 py-0.5 text-xs">
<span className="shrink-0 text-zinc-400">{label}</span>
<span className="truncate text-right text-zinc-200">{value}</span>
</div>
)
}
function Section({
title,
defaultOpen = true,
children,
}: {
title: string
defaultOpen?: boolean
children: React.ReactNode
}) {
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-xs font-medium text-zinc-300 hover:text-white">
{title}
<ChevronDownIcon className="h-3.5 w-3.5 transition-transform [[data-state=closed]>&]:rotate-(-90)" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-3">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
export function MetadataSidebar({ asset, onClose }: MetadataSidebarProps) {
const meta = asset.metadata
const width = meta.ExifImageWidth as string | undefined
const height = meta.ExifImageHeight as string | undefined
const cameraEntries = CAMERA_KEYS.filter((k) => meta[k] != null).map(
(k) => {
let val = String(meta[k])
if (k === "ExposureTime") val = formatExposure(val)
if (k === "FocalLength") val = formatFocalLength(val)
if (k === "FNumber") val = formatFNumber(val)
return [k, val] as const
},
)
const gpsEntries = GPS_KEYS.filter((k) => meta[k] != null)
const remainingEntries = Object.entries(meta).filter(
([k]) => !HIDDEN_KEYS.includes(k),
)
return (
<div className="flex w-80 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/90 backdrop-blur">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium text-zinc-200">Details</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-white/10"
onClick={onClose}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
<Separator className="bg-zinc-800" />
<ScrollArea className="flex-1 px-4">
{/* File Info */}
<Section title="File Info">
<div className="flex items-center gap-2 pb-1">
<Badge variant="secondary" className="text-xs">
{asset.asset_type}
</Badge>
<span className="text-xs text-zinc-400">{asset.mime_type}</span>
</div>
<MetaRow label="Size" value={formatBytes(asset.file_size)} />
{width && height && (
<MetaRow label="Dimensions" value={`${width} × ${height}`} />
)}
<MetaRow
label="Created"
value={new Date(asset.created_at).toLocaleString()}
/>
<MetaRow
label="Processed"
value={asset.is_processed ? "Yes" : "No"}
/>
</Section>
{/* Camera */}
{cameraEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="Camera">
{cameraEntries.map(([k, v]) => (
<MetaRow key={k} label={k} value={v} />
))}
</Section>
</>
)}
{/* Location */}
{gpsEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="Location">
{gpsEntries.map((k) => (
<MetaRow key={k} label={k} value={String(meta[k])} />
))}
</Section>
</>
)}
{/* All Metadata */}
{remainingEntries.length > 0 && (
<>
<Separator className="bg-zinc-800" />
<Section title="All Metadata" defaultOpen={false}>
{remainingEntries.map(([k, v]) => (
<MetaRow key={k} label={k} value={String(v)} />
))}
</Section>
</>
)}
</ScrollArea>
</div>
)
}