/** * ViewPackViewer — interactive 360° persona preview. * * Renders the currently-active angle as a large image plus a row of * clickable chips (Front / Left / Right / Back) that switch the active * angle instantly (all angles are preloaded on mount). Drop-in for any * chat surface that receives a message with a ``media.view_pack``. * * Parity note: Chat (``App.tsx``) has an inline version of this that * predates the component being extracted. This file is the shared, * additive version used by voice mode and any future surface — App.tsx * stays untouched so the chat rendering is not destabilized by the * extraction. */ import React, { useEffect, useState } from 'react' export type ViewAngle = 'front' | 'left' | 'right' | 'back' const VIEW_ANGLE_LABELS: Record = { front: 'Front', left: 'Left', right: 'Right', back: 'Back', } export interface ViewPackViewerProps { viewPack: Partial> availableViews: ViewAngle[] /** Initial angle. Defaults to the first available view, or 'front' if present. */ initialAngle?: ViewAngle /** Optional: notified whenever the user picks a different angle. */ onAngleChange?: (angle: ViewAngle, url: string) => void /** Optional: click on the large image — e.g. to open a lightbox. */ onImageClick?: (url: string) => void /** Styling hooks (both optional). */ imageClassName?: string containerClassName?: string } export function ViewPackViewer({ viewPack, availableViews, initialAngle, onAngleChange, onImageClick, imageClassName, containerClassName, }: ViewPackViewerProps) { const firstAvailable: ViewAngle = initialAngle && viewPack[initialAngle] ? initialAngle : availableViews.find((a) => viewPack[a]) || 'front' const [active, setActive] = useState(firstAvailable) // Preload every angle on mount so clicks feel instant. useEffect(() => { availableViews.forEach((angle) => { const url = viewPack[angle] if (url) { const img = new Image() img.src = url } }) }, [viewPack, availableViews]) // Keep active angle valid even if viewPack/availableViews change under us. useEffect(() => { if (!viewPack[active]) { const fallback = availableViews.find((a) => viewPack[a]) if (fallback) setActive(fallback) } }, [viewPack, availableViews, active]) const activeUrl = viewPack[active] || '' if (!activeUrl) return null return (
{`Persona onImageClick?.(activeUrl)} className={ imageClassName ?? 'w-72 max-h-96 h-auto object-contain rounded-xl border border-white/10 bg-black/20 cursor-zoom-in hover:opacity-90 transition-opacity' } onError={(e) => { ;(e.target as HTMLImageElement).style.display = 'none' }} />
{availableViews.map((angle) => { const url = viewPack[angle] if (!url) return null const isActive = angle === active return ( ) })}
) }