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:
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal file
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user