Files
k-photos/k-photos-frontend/components/metadata-sidebar.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

205 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}