The Metadata collapsible was hosting two editable widgets (TagsEditor and TakenAtEditor) buried inside the readonly sub-sections — Tags as its own Section, taken-at wedged into Basic Info between size/dims and the filepath. With both top-level collapsibles in place, the clearer split is editable up top, readonly below. Moved into the Edit collapsible (in identification → description → categorization order): Filename, Title, Date Taken, Notes, Tags, Rating, Color, Flag Metadata now holds only readonly sub-sections: Basic Info (size, dims, path), Camera, Location Dropped the now-empty Tags Section from Metadata and the 'tags' key from the default-expanded set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
752 lines
25 KiB
TypeScript
752 lines
25 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import {
|
||
X,
|
||
Star,
|
||
MapPin,
|
||
Camera,
|
||
Aperture,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
ShoppingBasket,
|
||
Trash2,
|
||
} from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import {
|
||
useQuery,
|
||
useMutation,
|
||
useQueryClient,
|
||
keepPreviousData,
|
||
} from '@tanstack/react-query'
|
||
import {
|
||
photos as photosApi,
|
||
heaps as heapsApi,
|
||
tags as tagsApi,
|
||
} from '../../services/api'
|
||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||
import { formatApiError } from '../../lib/apiError'
|
||
import { toast } from '../ToastContainer'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from '@/components/ui/collapsible'
|
||
import {
|
||
COLOR_LABEL_OPTIONS,
|
||
type ColorLabel,
|
||
} from '../../constants/colorLabels'
|
||
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
|
||
import { TagsEditor } from './TagsEditor'
|
||
import { TakenAtEditor } from './TakenAtEditor'
|
||
|
||
export interface PhotoTagSummary {
|
||
id: string
|
||
name: string
|
||
color: string | null
|
||
}
|
||
|
||
export interface PhotoDetails {
|
||
id: string
|
||
filename: string
|
||
filepath: string
|
||
width: number | null
|
||
height: number | null
|
||
file_size: number | null
|
||
taken_at: string | null
|
||
taken_at_source: string | null
|
||
rating: number
|
||
is_discarded: boolean
|
||
user_title: string | null
|
||
user_notes: string | null
|
||
color_label: string | null
|
||
exif_json: string | null
|
||
latitude?: number | null
|
||
longitude?: number | null
|
||
tags?: PhotoTagSummary[]
|
||
}
|
||
|
||
/** Format a signed decimal degree value with the hemisphere letter, e.g.
|
||
* ``48.12777° N``. Keeps the panel readable without dragging in a heavy
|
||
* formatting lib. */
|
||
function formatLatLon(value: number, axis: 'lat' | 'lon'): string {
|
||
const abs = Math.abs(value).toFixed(5)
|
||
const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W')
|
||
return `${abs}° ${ref}`
|
||
}
|
||
|
||
interface ExifData {
|
||
Make?: string
|
||
Model?: string
|
||
LensModel?: string
|
||
Lens?: string
|
||
ISO?: number | string
|
||
FNumber?: number | string
|
||
ApertureValue?: number | string
|
||
ExposureTime?: string
|
||
ShutterSpeedValue?: string
|
||
FocalLength?: string
|
||
FocalLengthIn35mmFormat?: string
|
||
GPSLatitude?: number | string
|
||
GPSLongitude?: number | string
|
||
[key: string]: unknown
|
||
}
|
||
|
||
function formatFileSize(bytes: number | null): string {
|
||
if (bytes == null) return '—'
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||
}
|
||
|
||
function formatExifValue(v: unknown): string {
|
||
if (v == null || v === '') return '—'
|
||
return String(v)
|
||
}
|
||
|
||
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
||
for (const k of keys) {
|
||
const v = exif[k]
|
||
if (v != null && v !== '') return String(v)
|
||
}
|
||
return '—'
|
||
}
|
||
|
||
function parseExif(json: string | null): ExifData {
|
||
if (!json) return {}
|
||
try {
|
||
const parsed = JSON.parse(json)
|
||
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
interface PhotoInfoPanelProps {
|
||
/** The photo to show metadata for. Drives an on-demand detail fetch. */
|
||
photoId: string
|
||
/** When true, the editable text fields (filename, title, notes) render
|
||
* with a darker theme to read against a black preview backdrop. */
|
||
darkTheme?: boolean
|
||
}
|
||
|
||
/**
|
||
* Reusable single-photo metadata + edit panel. Used by both the grid
|
||
* RightSidebar (when one photo is selected) and the PreviewView's optional
|
||
* info overlay. Self-contained — owns its own queries and mutations.
|
||
*/
|
||
export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelProps) {
|
||
const queryClient = useQueryClient()
|
||
|
||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||
new Set(['edit', 'metadata', 'basic', 'camera', 'location'])
|
||
)
|
||
const toggleSection = (section: string) => {
|
||
const next = new Set(expandedSections)
|
||
if (next.has(section)) next.delete(section)
|
||
else next.add(section)
|
||
setExpandedSections(next)
|
||
}
|
||
|
||
// Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
|
||
// holds the last photo on screen while the next one loads, so arrow-nav
|
||
// through the preview doesn't flash the "Loading…" placeholder between
|
||
// every neighbour — the panel swaps in place once the new record arrives.
|
||
const { data: photo, isPlaceholderData } = useQuery<PhotoDetails>({
|
||
queryKey: ['photo', photoId],
|
||
queryFn: () => photosApi.get(photoId),
|
||
enabled: !!photoId,
|
||
staleTime: 60_000,
|
||
placeholderData: keepPreviousData,
|
||
})
|
||
|
||
// Mutation for any patchable field. Invalidates both the photo detail
|
||
// cache and the timeline list so the grid reflects the change too.
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: {
|
||
filename?: string
|
||
rating?: number
|
||
is_discarded?: boolean
|
||
user_title?: string | null
|
||
user_notes?: string | null
|
||
color_label?: string | null
|
||
taken_at?: string
|
||
}) => photosApi.update(photoId, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||
},
|
||
})
|
||
|
||
// Active heap membership for the Pick toggle button.
|
||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||
const isInActiveHeap = activeHeapMembers.has(photoId)
|
||
|
||
const heapMutation = useMutation({
|
||
mutationFn: ({ remove }: { remove: boolean }) => {
|
||
if (!activeHeap) return Promise.resolve(null)
|
||
return remove
|
||
? heapsApi.removePhotos(activeHeap.id, [photoId])
|
||
: heapsApi.addPhotos(activeHeap.id, [photoId])
|
||
},
|
||
onMutate: ({ remove }) => {
|
||
if (!activeHeap) return { previous: undefined }
|
||
const key = ['heap-photo-ids', activeHeap.id] as const
|
||
const previous = queryClient.getQueryData<string[]>(key)
|
||
const set = new Set(previous ?? [])
|
||
if (remove) set.delete(photoId)
|
||
else set.add(photoId)
|
||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||
return { previous }
|
||
},
|
||
onError: (_e, _vars, ctx) => {
|
||
if (activeHeap && ctx?.previous) {
|
||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||
}
|
||
},
|
||
onSettled: () => {
|
||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||
if (activeHeap) {
|
||
queryClient.invalidateQueries({
|
||
queryKey: ['heap-photo-ids', activeHeap.id],
|
||
})
|
||
}
|
||
},
|
||
})
|
||
|
||
// ── Tags state + mutations ──────────────────────────────────────────
|
||
const { data: allTags = [] } = useTagsQuery()
|
||
const [tagInput, setTagInput] = useState('')
|
||
|
||
const invalidateTagsAndPhoto = () => {
|
||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||
}
|
||
|
||
const addTagMutation = useMutation({
|
||
mutationFn: async (name: string) => {
|
||
const created = await tagsApi.create(name)
|
||
await tagsApi.addToPhoto(photoId, [created.id])
|
||
return created
|
||
},
|
||
onSuccess: () => invalidateTagsAndPhoto(),
|
||
onError: (e: any) =>
|
||
toast.error('Add tag failed', formatApiError(e)),
|
||
})
|
||
|
||
const attachExistingTagMutation = useMutation({
|
||
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
|
||
onSuccess: () => invalidateTagsAndPhoto(),
|
||
onError: (e: any) =>
|
||
toast.error('Add tag failed', formatApiError(e)),
|
||
})
|
||
|
||
const removeTagMutation = useMutation({
|
||
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
|
||
onSuccess: () => invalidateTagsAndPhoto(),
|
||
onError: (e: any) =>
|
||
toast.error('Remove tag failed', formatApiError(e)),
|
||
})
|
||
|
||
// Local drafts for the text fields. Mirror the server value but stay
|
||
// independent while typing so we don't fight focus or clobber edits.
|
||
const [filenameDraft, setFilenameDraft] = useState('')
|
||
const [titleDraft, setTitleDraft] = useState('')
|
||
const [notesDraft, setNotesDraft] = useState('')
|
||
const [takenAtDraft, setTakenAtDraft] = useState('')
|
||
|
||
useEffect(() => {
|
||
setFilenameDraft(photo?.filename ?? '')
|
||
setTitleDraft(photo?.user_title ?? '')
|
||
setNotesDraft(photo?.user_notes ?? '')
|
||
setTakenAtDraft(
|
||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
||
)
|
||
}, [
|
||
photo?.id,
|
||
photo?.filename,
|
||
photo?.user_title,
|
||
photo?.user_notes,
|
||
photo?.taken_at,
|
||
])
|
||
|
||
const commitFilename = () => {
|
||
const next = filenameDraft.trim()
|
||
const current = photo?.filename ?? ''
|
||
if (!next || next === current) {
|
||
setFilenameDraft(current)
|
||
return
|
||
}
|
||
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
|
||
toast.error('Invalid filename', 'No path separators allowed')
|
||
setFilenameDraft(current)
|
||
return
|
||
}
|
||
updateMutation.mutate(
|
||
{ filename: next },
|
||
{
|
||
onError: (e: any) => {
|
||
toast.error(
|
||
'Rename failed',
|
||
formatApiError(e)
|
||
)
|
||
setFilenameDraft(current)
|
||
},
|
||
}
|
||
)
|
||
}
|
||
|
||
const commitTitle = () => {
|
||
const next = titleDraft.trim()
|
||
const current = photo?.user_title ?? ''
|
||
if (next === current) return
|
||
updateMutation.mutate({ user_title: next || null })
|
||
}
|
||
|
||
const commitNotes = () => {
|
||
const next = notesDraft
|
||
const current = photo?.user_notes ?? ''
|
||
if (next === current) return
|
||
updateMutation.mutate({ user_notes: next || null })
|
||
}
|
||
|
||
/** Commit a datetime-local draft back to the server. The backend also
|
||
* rewrites EXIF on disk, so a failure here rolls the draft back to the
|
||
* server value — we never want the UI to silently disagree with the
|
||
* file. An empty string is a no-op because the input's `required` is
|
||
* off and we don't yet have a "clear date" affordance. */
|
||
const commitTakenAt = (rawValue?: string) => {
|
||
const source = rawValue ?? takenAtDraft
|
||
if (!source) return
|
||
const parsed = new Date(source)
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
toast.error('Invalid date', 'Could not parse the value')
|
||
setTakenAtDraft(
|
||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
||
)
|
||
return
|
||
}
|
||
const iso = parsed.toISOString()
|
||
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
|
||
return
|
||
}
|
||
updateMutation.mutate(
|
||
{ taken_at: iso },
|
||
{
|
||
onError: (e: any) => {
|
||
toast.error(
|
||
'Date update failed',
|
||
formatApiError(e)
|
||
)
|
||
setTakenAtDraft(
|
||
photo?.taken_at
|
||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
||
: ''
|
||
)
|
||
},
|
||
}
|
||
)
|
||
}
|
||
|
||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||
|
||
if (!photo) {
|
||
return <div className="p-4 text-xs text-text-muted">Loading…</div>
|
||
}
|
||
|
||
const rating = photo.rating ?? 0
|
||
const isDiscarded = photo.is_discarded ?? false
|
||
const colorLabel = (photo.color_label ?? null) as ColorLabel | null
|
||
|
||
// Single themable input class so the same component reads against either
|
||
// the surface (grid sidebar) or a darker preview overlay.
|
||
const inputClass = cn(
|
||
'w-full rounded border px-2 py-1 text-sm focus:outline-none',
|
||
darkTheme
|
||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||
)
|
||
const monoInputClass = cn(
|
||
'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
|
||
darkTheme
|
||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||
)
|
||
|
||
// Note: no h-full / flex-1 here — the parent (RightSidebar) owns the
|
||
// scroll container so the edit fields and readonly metadata scroll
|
||
// together as one block beneath the pinned heap + header.
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex flex-col transition-opacity duration-150',
|
||
isPlaceholderData && 'opacity-70'
|
||
)}
|
||
>
|
||
{/* Edit fields — collapsible group so the user can hide the
|
||
* editable form (filename, title, notes, rating, color, flag)
|
||
* the same way they can hide the readonly metadata block below. */}
|
||
<Collapsible
|
||
open={expandedSections.has('edit')}
|
||
onOpenChange={() => toggleSection('edit')}
|
||
className="border-b border-border"
|
||
>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
|
||
<span>Edit</span>
|
||
{expandedSections.has('edit') ? (
|
||
<ChevronDown className="h-3 w-3" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3" />
|
||
)}
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="space-y-2.5 p-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||
<Input
|
||
type="text"
|
||
value={filenameDraft}
|
||
onChange={(e) => setFilenameDraft(e.target.value)}
|
||
onBlur={commitFilename}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.currentTarget.blur()
|
||
} else if (e.key === 'Escape') {
|
||
setFilenameDraft(photo.filename ?? '')
|
||
e.currentTarget.blur()
|
||
}
|
||
}}
|
||
className={monoInputClass}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||
<Input
|
||
type="text"
|
||
value={titleDraft}
|
||
onChange={(e) => setTitleDraft(e.target.value)}
|
||
onBlur={commitTitle}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.currentTarget.blur()
|
||
} else if (e.key === 'Escape') {
|
||
setTitleDraft(photo.user_title ?? '')
|
||
e.currentTarget.blur()
|
||
}
|
||
}}
|
||
placeholder="No title"
|
||
className={inputClass}
|
||
/>
|
||
</div>
|
||
|
||
<TakenAtEditor
|
||
photo={photo}
|
||
draft={takenAtDraft}
|
||
onDraftChange={setTakenAtDraft}
|
||
onCommit={commitTakenAt}
|
||
darkTheme={darkTheme}
|
||
/>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
||
<Textarea
|
||
value={notesDraft}
|
||
onChange={(e) => setNotesDraft(e.target.value)}
|
||
onBlur={commitNotes}
|
||
placeholder="Add notes…"
|
||
rows={3}
|
||
className={cn(inputClass, 'resize-none')}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Tags</label>
|
||
<TagsEditor
|
||
photoTags={photo.tags ?? []}
|
||
allTags={allTags}
|
||
tagInput={tagInput}
|
||
onTagInputChange={setTagInput}
|
||
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
||
onCreateAndAttach={(name) => {
|
||
addTagMutation.mutate(name)
|
||
setTagInput('')
|
||
}}
|
||
onRemove={(id) => removeTagMutation.mutate(id)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Rating */}
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||
<div className="flex gap-1">
|
||
{[1, 2, 3, 4, 5].map((value) => (
|
||
<button
|
||
key={value}
|
||
onClick={() =>
|
||
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
||
}
|
||
className="p-0.5"
|
||
title={`Set rating to ${value}`}
|
||
>
|
||
<Star
|
||
className={cn(
|
||
'h-5 w-5 transition-colors',
|
||
value <= rating
|
||
? 'fill-star text-star'
|
||
: 'text-text-muted hover:text-star'
|
||
)}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Color label */}
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||
<div className="flex items-center gap-1.5">
|
||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||
const active = colorLabel === value
|
||
return (
|
||
<button
|
||
key={value}
|
||
onClick={() =>
|
||
updateMutation.mutate({ color_label: active ? null : value })
|
||
}
|
||
className={cn(
|
||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||
className,
|
||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||
)}
|
||
title={value}
|
||
/>
|
||
)
|
||
})}
|
||
{colorLabel && (
|
||
<button
|
||
onClick={() => updateMutation.mutate({ color_label: null })}
|
||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||
title="Clear color label"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Flag — Select + Discard */}
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => {
|
||
if (!activeHeap) return
|
||
heapMutation.mutate({ remove: isInActiveHeap })
|
||
}}
|
||
disabled={!activeHeap || heapMutation.isPending}
|
||
className={cn(
|
||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||
isInActiveHeap
|
||
? 'bg-pick/20 text-pick'
|
||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||
)}
|
||
title={
|
||
activeHeap
|
||
? isInActiveHeap
|
||
? `Remove from "${activeHeap.name}"`
|
||
: `Add to "${activeHeap.name}"`
|
||
: 'Set an active heap first'
|
||
}
|
||
>
|
||
<ShoppingBasket className="h-3 w-3" />
|
||
{isInActiveHeap ? 'Selected' : 'Select'}
|
||
</button>
|
||
<button
|
||
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
||
className={cn(
|
||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||
isDiscarded
|
||
? 'bg-reject/20 text-reject'
|
||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||
)}
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
Discard
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Read-only metadata — collapsed/expanded as one block so the user
|
||
* can hide everything below the editable form with a single click.
|
||
* Sub-sections inside stay individually collapsible for finer
|
||
* control once the outer group is open. */}
|
||
<Collapsible
|
||
open={expandedSections.has('metadata')}
|
||
onOpenChange={() => toggleSection('metadata')}
|
||
className="border-b border-border"
|
||
>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
|
||
<span>Metadata</span>
|
||
{expandedSections.has('metadata') ? (
|
||
<ChevronDown className="h-3 w-3" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3" />
|
||
)}
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<Section
|
||
title="Basic Info"
|
||
expanded={expandedSections.has('basic')}
|
||
onToggle={() => toggleSection('basic')}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||
<Field
|
||
label="Dimensions"
|
||
value={
|
||
photo.width && photo.height
|
||
? `${photo.width} × ${photo.height}`
|
||
: '—'
|
||
}
|
||
/>
|
||
</div>
|
||
{/* Filepath spans the full sidebar width — most paths are long
|
||
* enough that the two-column grid above wraps them painfully.
|
||
* Mono so each character lines up under the next, break-all
|
||
* so we never overflow horizontally on a long basename. */}
|
||
<div className="mt-2 text-xs">
|
||
<span className="text-text-muted">Path:</span>
|
||
<p className="mt-0.5 break-all font-mono text-[11px] text-text" title={photo.filepath}>
|
||
{photo.filepath || '—'}
|
||
</p>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="Camera"
|
||
expanded={expandedSections.has('camera')}
|
||
onToggle={() => toggleSection('camera')}
|
||
>
|
||
<div className="space-y-1 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<Camera className="h-3 w-3 text-text-muted" />
|
||
<span className="text-text">
|
||
{pickFirst(exif, 'Make', 'Model') === '—'
|
||
? '—'
|
||
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Aperture className="h-3 w-3 text-text-muted" />
|
||
<span className="text-text">
|
||
{pickFirst(exif, 'LensModel', 'Lens')}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
||
<Field
|
||
label="Aperture"
|
||
value={
|
||
exif.FNumber
|
||
? `f/${exif.FNumber}`
|
||
: pickFirst(exif, 'ApertureValue')
|
||
}
|
||
/>
|
||
<Field
|
||
label="Shutter"
|
||
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
||
/>
|
||
<Field
|
||
label="Focal"
|
||
value={pickFirst(
|
||
exif,
|
||
'FocalLength',
|
||
'FocalLengthIn35mmFormat'
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="Location"
|
||
expanded={expandedSections.has('location')}
|
||
onToggle={() => toggleSection('location')}
|
||
>
|
||
{photo.latitude != null && photo.longitude != null ? (
|
||
<a
|
||
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="flex items-center gap-2 text-xs hover:underline"
|
||
title="Open in OpenStreetMap"
|
||
>
|
||
<MapPin className="h-3 w-3 text-text-muted" />
|
||
<span className="font-mono text-text">
|
||
{formatLatLon(photo.latitude, 'lat')},{' '}
|
||
{formatLatLon(photo.longitude, 'lon')}
|
||
</span>
|
||
</a>
|
||
) : (
|
||
<div className="text-xs text-text-muted">No GPS data</div>
|
||
)}
|
||
</Section>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Section({
|
||
title,
|
||
expanded,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
title: string
|
||
expanded: boolean
|
||
onToggle: () => void
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<Collapsible
|
||
open={expanded}
|
||
onOpenChange={onToggle}
|
||
className="border-b border-border"
|
||
>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
|
||
<span>{title}</span>
|
||
{expanded ? (
|
||
<ChevronDown className="h-3 w-3" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3" />
|
||
)}
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="px-3 pb-2.5">
|
||
{children}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
)
|
||
}
|
||
|
||
function Field({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<span className="text-text-muted">{label}:</span>
|
||
<p className="break-words text-text">{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|