File size: 3,907 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/**
 * 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<ViewAngle, string> = {
  front: 'Front',
  left: 'Left',
  right: 'Right',
  back: 'Back',
}

export interface ViewPackViewerProps {
  viewPack: Partial<Record<ViewAngle, string>>
  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<ViewAngle>(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 (
    <div className={containerClassName ?? 'mt-3 hp-fade-in'}>
      <img
        src={activeUrl}
        alt={`Persona ${VIEW_ANGLE_LABELS[active]} view`}
        onClick={() => 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'
        }}
      />
      <div className="flex gap-1.5 pt-2">
        {availableViews.map((angle) => {
          const url = viewPack[angle]
          if (!url) return null
          const isActive = angle === active
          return (
            <button
              key={angle}
              type="button"
              onClick={() => {
                setActive(angle)
                onAngleChange?.(angle, url)
              }}
              className={`px-3 py-1 text-xs rounded-full border transition-colors ${
                isActive
                  ? 'bg-white/15 border-white/30 text-white/90 font-medium'
                  : 'bg-white/[0.04] border-white/10 text-white/50 hover:bg-white/10 hover:text-white/70'
              }`}
              aria-pressed={isActive}
            >
              {VIEW_ANGLE_LABELS[angle]}
            </button>
          )
        })}
      </div>
    </div>
  )
}