feat: wire RightSidebar to real photo metadata
Replaces the mock photo data with a TanStack Query fetch driven by
activePhotoId, so the metadata panel reflects the photo currently
selected (or being viewed in the loupe). Parses exif_json defensively
and renders ExifTool fields with sensible aliases (Make/Model,
LensModel/Lens, FNumber, ExposureTime/ShutterSpeedValue, FocalLength,
GPSLatitude/Longitude). Falls back to '—' for missing fields.
Rating stars and Pick/Reject buttons now fire useMutation against
PATCH /photos/{id} and invalidate both the photo detail query and the
['photos'] list query so the timeline grid reflects the change too.
Multi-select keeps its bulk-action footer and shows the selection
count instead of metadata.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Star,
|
Star,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -9,46 +9,114 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
Check,
|
||||||
Plus
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { photos as photosApi } from '../../services/api'
|
||||||
|
|
||||||
|
interface PhotoDetails {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
filepath: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
file_size: number | null
|
||||||
|
taken_at: string | null
|
||||||
|
rating: number
|
||||||
|
is_picked: boolean
|
||||||
|
is_rejected: boolean
|
||||||
|
exif_json: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const { selectedPhotos, clearSelection } = usePhotoStore()
|
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set(['basic', 'camera', 'location', 'tags'])
|
new Set(['basic', 'camera', 'location'])
|
||||||
)
|
)
|
||||||
const [rating, setRating] = useState(0)
|
|
||||||
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
const toggleSection = (section: string) => {
|
||||||
const newExpanded = new Set(expandedSections)
|
const newExpanded = new Set(expandedSections)
|
||||||
if (newExpanded.has(section)) {
|
if (newExpanded.has(section)) newExpanded.delete(section)
|
||||||
newExpanded.delete(section)
|
else newExpanded.add(section)
|
||||||
} else {
|
|
||||||
newExpanded.add(section)
|
|
||||||
}
|
|
||||||
setExpandedSections(newExpanded)
|
setExpandedSections(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock photo data - in real app, fetch based on selectedPhotos
|
// Fetch the active photo's full record (with EXIF) on demand.
|
||||||
const mockPhoto = selectedPhotos.length > 0 ? {
|
const { data: photo } = useQuery<PhotoDetails>({
|
||||||
filename: 'IMG_1234.jpg',
|
queryKey: ['photo', activePhotoId],
|
||||||
size: '3.2 MB',
|
queryFn: () => photosApi.get(activePhotoId!),
|
||||||
dimensions: '4032 × 3024',
|
enabled: !!activePhotoId,
|
||||||
dateTaken: new Date('2024-01-15T14:30:00'),
|
staleTime: 60_000,
|
||||||
camera: 'Canon EOS R5',
|
})
|
||||||
lens: 'RF 24-70mm F2.8L IS USM',
|
|
||||||
iso: 400,
|
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
||||||
aperture: 'f/2.8',
|
// photo query and the timeline list query so the grid re-renders too.
|
||||||
shutterSpeed: '1/250',
|
const updateMutation = useMutation({
|
||||||
focalLength: '50mm',
|
mutationFn: (data: {
|
||||||
location: 'San Francisco, CA',
|
rating?: number
|
||||||
tags: ['landscape', 'sunset', 'golden hour'],
|
is_picked?: boolean
|
||||||
} : null
|
is_rejected?: boolean
|
||||||
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
@@ -59,232 +127,205 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const multipleSelected = selectedPhotos.length > 1
|
const multipleSelected = selectedPhotos.length > 1
|
||||||
|
const rating = photo?.rating ?? 0
|
||||||
|
const isPicked = photo?.is_picked ?? false
|
||||||
|
const isRejected = photo?.is_rejected ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold text-text">
|
<h2 className="text-sm font-semibold text-text">
|
||||||
{multipleSelected
|
{multipleSelected
|
||||||
? `${selectedPhotos.length} Photos Selected`
|
? `${selectedPhotos.length} Photos Selected`
|
||||||
: 'Photo Details'}
|
: 'Photo Details'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear selection"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions — operate on the active photo */}
|
||||||
<div className="border-b border-border p-4">
|
{photo && !multipleSelected && (
|
||||||
{/* Rating Stars */}
|
<div className="border-b border-border p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[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={clsx(
|
||||||
|
'h-5 w-5 transition-colors',
|
||||||
|
value <= rating
|
||||||
|
? 'fill-star text-star'
|
||||||
|
: 'text-text-muted hover:text-star'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
key={value}
|
onClick={() =>
|
||||||
onClick={() => setRating(rating === value ? 0 : value)}
|
updateMutation.mutate({
|
||||||
className="p-0.5"
|
is_picked: !isPicked,
|
||||||
|
is_rejected: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
|
isPicked
|
||||||
|
? 'bg-pick/20 text-pick'
|
||||||
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Star
|
<Check className="h-3 w-3" />
|
||||||
className={clsx(
|
Pick
|
||||||
'h-5 w-5 transition-colors',
|
|
||||||
value <= rating
|
|
||||||
? 'fill-star text-star'
|
|
||||||
: 'text-text-muted hover:text-star'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
is_rejected: !isRejected,
|
||||||
|
is_picked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
|
isRejected
|
||||||
|
? 'bg-reject/20 text-reject'
|
||||||
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Flag Status */}
|
|
||||||
<div>
|
{/* Metadata */}
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
|
||||||
flagStatus === 'pick'
|
|
||||||
? 'bg-pick/20 text-pick'
|
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
Pick
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
|
||||||
flagStatus === 'reject'
|
|
||||||
? 'bg-reject/20 text-reject'
|
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata Sections */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{mockPhoto && (
|
{photo && !multipleSelected && (
|
||||||
<>
|
<>
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="border-b border-border">
|
<Section
|
||||||
<button
|
title="Basic Info"
|
||||||
onClick={() => toggleSection('basic')}
|
expanded={expandedSections.has('basic')}
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
onToggle={() => toggleSection('basic')}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-text">Basic Info</span>
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
{expandedSections.has('basic') ? (
|
<Field label="Filename" value={photo.filename} />
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||||||
) : (
|
<Field
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
label="Dimensions"
|
||||||
)}
|
value={
|
||||||
</button>
|
photo.width && photo.height
|
||||||
{expandedSections.has('basic') && (
|
? `${photo.width} × ${photo.height}`
|
||||||
<div className="px-4 pb-3 text-xs">
|
: '—'
|
||||||
<div className="grid grid-cols-2 gap-2">
|
}
|
||||||
<div>
|
/>
|
||||||
<span className="text-text-muted">Filename:</span>
|
<Field
|
||||||
<p className="text-text">{mockPhoto.filename}</p>
|
label="Date Taken"
|
||||||
</div>
|
value={
|
||||||
<div>
|
photo.taken_at
|
||||||
<span className="text-text-muted">Size:</span>
|
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||||||
<p className="text-text">{mockPhoto.size}</p>
|
: '—'
|
||||||
</div>
|
}
|
||||||
<div>
|
/>
|
||||||
<span className="text-text-muted">Dimensions:</span>
|
</div>
|
||||||
<p className="text-text">{mockPhoto.dimensions}</p>
|
</Section>
|
||||||
</div>
|
|
||||||
<div>
|
{/* Camera */}
|
||||||
<span className="text-text-muted">Date Taken:</span>
|
<Section
|
||||||
<p className="text-text">
|
title="Camera"
|
||||||
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
|
expanded={expandedSections.has('camera')}
|
||||||
</p>
|
onToggle={() => toggleSection('camera')}
|
||||||
</div>
|
>
|
||||||
</div>
|
<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>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Aperture className="h-3 w-3 text-text-muted" />
|
||||||
|
<span className="text-text">
|
||||||
{/* Camera Info */}
|
{pickFirst(exif, 'LensModel', 'Lens')}
|
||||||
<div className="border-b border-border">
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('camera')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Camera</span>
|
|
||||||
{expandedSections.has('camera') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('camera') && (
|
|
||||||
<div className="px-4 pb-3 text-xs">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Camera className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.camera}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Aperture className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.lens}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">ISO:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.iso}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Aperture:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Shutter:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Focal:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="border-b border-border">
|
<Section
|
||||||
<button
|
title="Location"
|
||||||
onClick={() => toggleSection('location')}
|
expanded={expandedSections.has('location')}
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
onToggle={() => toggleSection('location')}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-text">Location</span>
|
{exif.GPSLatitude && exif.GPSLongitude ? (
|
||||||
{expandedSections.has('location') ? (
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
<MapPin className="h-3 w-3 text-text-muted" />
|
||||||
) : (
|
<span className="font-mono text-text">
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||||||
)}
|
</span>
|
||||||
</button>
|
|
||||||
{expandedSections.has('location') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<MapPin className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-muted">No GPS data</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('tags')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Tags</span>
|
|
||||||
{expandedSections.has('tags') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('tags') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{mockPhoto.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!photo && !multipleSelected && (
|
||||||
|
<div className="p-4 text-xs text-text-muted">Loading…</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions for multi-select */}
|
||||||
{multipleSelected && (
|
{multipleSelected && (
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -299,4 +340,42 @@ export function RightSidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-text">{title}</span>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && <div className="px-4 pb-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user