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
205 lines
5.8 KiB
TypeScript
205 lines
5.8 KiB
TypeScript
"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>
|
||
)
|
||
}
|