@terrafirmagreg-team/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/package.json +93 -3
  2. package/src/components/blocks/button/icon-button.tsx +48 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +134 -0
  4. package/src/components/blocks/canvas/flow-canvas.tsx +177 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +32 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +243 -0
  7. package/src/components/blocks/git/github-info.tsx +77 -0
  8. package/src/components/blocks/lang/content.tsx +22 -0
  9. package/src/components/blocks/lang/flag.tsx +24 -0
  10. package/src/components/blocks/lang/link.tsx +22 -0
  11. package/src/components/blocks/lang/redirect.tsx +47 -0
  12. package/src/components/blocks/lang/switcher.tsx +53 -0
  13. package/src/components/blocks/lang/untranslated-callout.tsx +28 -0
  14. package/src/components/blocks/link/link-preview.tsx +256 -0
  15. package/src/components/blocks/list/row.tsx +91 -0
  16. package/src/components/blocks/media/glb/model.tsx +243 -0
  17. package/src/components/blocks/media/glb/scene.tsx +372 -0
  18. package/src/components/blocks/media/glb/viewer.tsx +51 -0
  19. package/src/components/blocks/media/image.tsx +135 -0
  20. package/src/components/blocks/media/slideshow.tsx +177 -0
  21. package/src/components/blocks/nav/breadcrumb.tsx +50 -0
  22. package/src/components/blocks/nav/link.tsx +85 -0
  23. package/src/components/blocks/nav/site-menu.tsx +77 -0
  24. package/src/components/blocks/nav/site-title.tsx +60 -0
  25. package/src/components/blocks/nav/switcher.tsx +205 -0
  26. package/src/components/blocks/page/empty-state.tsx +31 -0
  27. package/src/components/blocks/page/heading.tsx +53 -0
  28. package/src/components/blocks/page/pager.tsx +112 -0
  29. package/src/components/blocks/readability/menu.tsx +431 -0
  30. package/src/components/blocks/readability/track.tsx +164 -0
  31. package/src/components/blocks/search/command.tsx +79 -0
  32. package/src/components/blocks/search/dialog.tsx +712 -0
  33. package/src/components/blocks/search/provider.tsx +117 -0
  34. package/src/components/blocks/sidebar.tsx +406 -0
  35. package/src/components/blocks/theme/toggle.tsx +71 -0
  36. package/src/components/blocks/tooltip/hint.tsx +27 -0
  37. package/src/components/blocks/viewer/controls.tsx +279 -0
  38. package/src/components/blocks/viewer/float-bar.tsx +50 -0
  39. package/src/components/layouts/layout.tsx +93 -0
  40. package/src/components/layouts/left-sidebar/content.tsx +62 -0
  41. package/src/components/layouts/left-sidebar/context.tsx +54 -0
  42. package/src/components/layouts/left-sidebar/expand.tsx +22 -0
  43. package/src/components/layouts/left-sidebar/footer.tsx +105 -0
  44. package/src/components/layouts/left-sidebar/index.ts +28 -0
  45. package/src/components/layouts/left-sidebar/items.tsx +102 -0
  46. package/src/components/layouts/left-sidebar/search.tsx +74 -0
  47. package/src/components/layouts/left-sidebar/sidebar.tsx +188 -0
  48. package/src/components/layouts/left-sidebar/tree.tsx +156 -0
  49. package/src/components/layouts/right-sidebar.tsx +95 -0
  50. package/src/components/layouts/tree-context.tsx +41 -0
  51. package/src/components/mdx/accordion.tsx +184 -0
  52. package/src/components/mdx/banner.tsx +154 -0
  53. package/src/components/mdx/callout.tsx +96 -0
  54. package/src/components/mdx/card.tsx +49 -0
  55. package/src/components/mdx/codeblock.tsx +249 -0
  56. package/src/components/mdx/collapsible.tsx +32 -0
  57. package/src/components/mdx/files.tsx +62 -0
  58. package/src/components/mdx/glow-header.tsx +40 -0
  59. package/src/components/mdx/gradient-text.tsx +27 -0
  60. package/src/components/mdx/image-zoom.tsx +314 -0
  61. package/src/components/mdx/mermaid.tsx +16 -0
  62. package/src/components/mdx/multiblock.tsx +18 -0
  63. package/src/components/mdx/preview.tsx +100 -0
  64. package/src/components/mdx/steps.tsx +9 -0
  65. package/src/components/mdx/table.tsx +13 -0
  66. package/src/components/mdx/tabs.tsx +353 -0
  67. package/src/components/mdx/type-table.tsx +170 -0
  68. package/src/components/ui/accordion.tsx +71 -0
  69. package/src/components/ui/alert-dialog.tsx +187 -0
  70. package/src/components/ui/alert.tsx +75 -0
  71. package/src/components/ui/aspect-ratio.tsx +22 -0
  72. package/src/components/ui/attachment.tsx +207 -0
  73. package/src/components/ui/avatar.tsx +108 -0
  74. package/src/components/ui/badge.tsx +51 -0
  75. package/src/components/ui/breadcrumb.tsx +124 -0
  76. package/src/components/ui/bubble.tsx +127 -0
  77. package/src/components/ui/button-group.tsx +87 -0
  78. package/src/components/ui/button.tsx +57 -0
  79. package/src/components/ui/calendar.tsx +221 -0
  80. package/src/components/ui/card.tsx +102 -0
  81. package/src/components/ui/carousel.tsx +242 -0
  82. package/src/components/ui/chart.tsx +371 -0
  83. package/src/components/ui/checkbox.tsx +28 -0
  84. package/src/components/ui/collapsible.tsx +21 -0
  85. package/src/components/ui/combobox.tsx +297 -0
  86. package/src/components/ui/command.tsx +198 -0
  87. package/src/components/ui/context-menu.tsx +270 -0
  88. package/src/components/ui/dialog.tsx +160 -0
  89. package/src/components/ui/direction.tsx +6 -0
  90. package/src/components/ui/drawer.tsx +227 -0
  91. package/src/components/ui/dropdown-menu.tsx +269 -0
  92. package/src/components/ui/empty.tsx +103 -0
  93. package/src/components/ui/field.tsx +238 -0
  94. package/src/components/ui/hover-card.tsx +50 -0
  95. package/src/components/ui/input-group.tsx +158 -0
  96. package/src/components/ui/input-otp.tsx +86 -0
  97. package/src/components/ui/input.tsx +19 -0
  98. package/src/components/ui/item.tsx +201 -0
  99. package/src/components/ui/kbd.tsx +26 -0
  100. package/src/components/ui/label.tsx +19 -0
  101. package/src/components/ui/marker.tsx +70 -0
  102. package/src/components/ui/menubar.tsx +280 -0
  103. package/src/components/ui/message-scroller.tsx +131 -0
  104. package/src/components/ui/message.tsx +91 -0
  105. package/src/components/ui/native-select.tsx +60 -0
  106. package/src/components/ui/navigation-menu.tsx +167 -0
  107. package/src/components/ui/pagination.tsx +130 -0
  108. package/src/components/ui/popover.tsx +91 -0
  109. package/src/components/ui/progress.tsx +82 -0
  110. package/src/components/ui/questionnaire.tsx +324 -0
  111. package/src/components/ui/radio-group.tsx +37 -0
  112. package/src/components/ui/resizable.tsx +49 -0
  113. package/src/components/ui/scroll-area.tsx +54 -0
  114. package/src/components/ui/select.tsx +200 -0
  115. package/src/components/ui/separator.tsx +24 -0
  116. package/src/components/ui/sheet.tsx +138 -0
  117. package/src/components/ui/sidebar.tsx +730 -0
  118. package/src/components/ui/skeleton.tsx +13 -0
  119. package/src/components/ui/slider.tsx +51 -0
  120. package/src/components/ui/spinner.tsx +10 -0
  121. package/src/components/ui/switch.tsx +31 -0
  122. package/src/components/ui/table.tsx +115 -0
  123. package/src/components/ui/tabs.tsx +81 -0
  124. package/src/components/ui/textarea.tsx +17 -0
  125. package/src/components/ui/toast.tsx +234 -0
  126. package/src/components/ui/toggle-group.tsx +89 -0
  127. package/src/components/ui/toggle.tsx +44 -0
  128. package/src/components/ui/tooltip.tsx +65 -0
  129. package/src/hooks/canvas/use-flow-hover.ts +18 -0
  130. package/src/hooks/canvas/use-flow-viewport-persist.ts +33 -0
  131. package/src/hooks/lang.ts +8 -0
  132. package/src/hooks/layout.ts +18 -0
  133. package/src/hooks/page/use-fit-pages.ts +91 -0
  134. package/src/hooks/page/use-location-search.ts +25 -0
  135. package/src/hooks/page/use-page-preview.ts +40 -0
  136. package/src/hooks/page/use-paged-list.ts +20 -0
  137. package/src/hooks/readability/provider.ts +86 -0
  138. package/src/hooks/search/dialog.ts +41 -0
  139. package/src/hooks/search/provider.ts +25 -0
  140. package/src/hooks/search/use-highlight-query.ts +51 -0
  141. package/src/hooks/sidebar.ts +63 -0
  142. package/src/hooks/use-async-value.ts +57 -0
  143. package/src/hooks/use-close-on-scroll.ts +23 -0
  144. package/src/hooks/use-copy-button.ts +46 -0
  145. package/src/hooks/use-cycle-index.ts +75 -0
  146. package/src/hooks/use-document-title.ts +9 -0
  147. package/src/hooks/use-element-size.ts +35 -0
  148. package/src/hooks/use-external-meta.ts +132 -0
  149. package/src/hooks/use-hot-key.ts +33 -0
  150. package/src/hooks/use-hover-follow.ts +54 -0
  151. package/src/hooks/use-in-view.ts +30 -0
  152. package/src/hooks/use-last-value.ts +15 -0
  153. package/src/hooks/use-marquee.ts +51 -0
  154. package/src/hooks/use-media-query.ts +14 -0
  155. package/src/hooks/use-mobile.ts +19 -0
  156. package/src/hooks/use-modifier-keys.ts +31 -0
  157. package/src/hooks/use-on-change.ts +35 -0
  158. package/src/hooks/use-pane-keys.ts +28 -0
  159. package/src/hooks/use-swipe.ts +26 -0
  160. package/src/hooks/use-tree.ts +102 -0
  161. package/src/hooks/viewer/use-in-float-bar.ts +6 -0
  162. package/src/hooks/viewer/use-viewer-prefs.ts +77 -0
  163. package/src/lib/canvas/edge.ts +173 -0
  164. package/src/lib/canvas/geometry.ts +53 -0
  165. package/src/lib/canvas/state.ts +47 -0
  166. package/src/lib/canvas/viewport.ts +37 -0
  167. package/src/lib/canvas/xyflow.ts +1 -0
  168. package/src/lib/config.ts +81 -0
  169. package/src/lib/mdx/callout.ts +20 -0
  170. package/src/lib/mdx/gradient.ts +15 -0
  171. package/src/lib/mdx/mermaid.ts +14 -0
  172. package/src/lib/media/crop-opaque.ts +76 -0
  173. package/src/lib/media/nearby-indexes.ts +6 -0
  174. package/src/lib/media/trim-transparent.ts +117 -0
  175. package/src/lib/readability/config.ts +10 -0
  176. package/src/lib/readability/spotlight.ts +186 -0
  177. package/src/lib/readability/store.ts +146 -0
  178. package/src/lib/tree.ts +92 -0
  179. package/src/lib/utils/async-cache.ts +32 -0
  180. package/src/lib/utils/cn.ts +1 -0
  181. package/src/lib/utils/format.ts +22 -0
  182. package/src/lib/utils/history.ts +9 -0
  183. package/src/lib/utils/merge-refs.ts +13 -0
  184. package/src/lib/utils/nav-active.ts +58 -0
  185. package/src/lib/utils/stored-value.ts +136 -0
  186. package/src/lib/utils/urls.ts +17 -0
  187. package/src/locales/en_us.json +119 -0
  188. package/src/locales/pl_pl.json +35 -0
  189. package/src/locales/ru_ru.json +119 -0
  190. package/src/locales/uk_ua.json +35 -0
  191. package/src/locales/zh_cn.json +17 -0
  192. package/src/locales/zh_tw.json +17 -0
  193. package/src/locales.ts +5 -0
  194. package/src/provider.tsx +141 -0
  195. package/src/types/canvas.ts +49 -0
  196. package/src/types/glb.ts +25 -0
  197. package/src/types/nav.ts +95 -0
  198. package/src/types/preview.ts +9 -0
  199. package/src/types/readability.ts +28 -0
  200. package/src/types/search.ts +56 -0
  201. package/src/types/tree.ts +18 -0
  202. package/src/types/viewer.ts +53 -0
  203. package/styles/content/base.css +194 -0
  204. package/styles/content/default-colors.css +51 -0
  205. package/styles/content/shiki.css +108 -0
  206. package/styles/content/typography.mjs +3941 -0
  207. package/styles/content.css +7 -0
  208. package/styles/image-zoom.css +83 -0
  209. package/styles/layout.css +28 -0
  210. package/styles/left-sidebar.css +140 -0
  211. package/styles/readability.css +50 -0
  212. package/styles/right-sidebar.css +6 -0
  213. package/styles/workspace.css +26 -0
  214. package/styles.css +413 -0
  215. package/README.md +0 -3
@@ -0,0 +1,372 @@
1
+ 'use client';
2
+
3
+ import { OrbitControls } from '@react-three/drei';
4
+ import { Canvas, useLoader } from '@react-three/fiber';
5
+ import {
6
+ Component,
7
+ Suspense,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ type ReactNode,
14
+ } from 'react';
15
+ import {
16
+ Gauge,
17
+ Grid3x3,
18
+ MoveHorizontal,
19
+ MoveVertical,
20
+ Play,
21
+ RotateCw,
22
+ Timer,
23
+ } from 'lucide-react';
24
+ import type { WebGLRenderer } from 'three';
25
+ import {
26
+ ViewerToolbar,
27
+ ViewerControls,
28
+ } from '@terrafirmagreg-team/ui/components/blocks/viewer/controls';
29
+ import {
30
+ GlbModel,
31
+ glbLoaderClass,
32
+ GlbSceneApi,
33
+ } from '@terrafirmagreg-team/ui/components/blocks/media/glb/model';
34
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
35
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
36
+ import {
37
+ AUTOPLAY_SPEED_MAX,
38
+ AUTOPLAY_SPEED_MIN,
39
+ ROTATE_MAX,
40
+ ROTATE_MIN,
41
+ SCENE_HEIGHT_MAX,
42
+ SCENE_HEIGHT_MIN,
43
+ SCENE_WIDTH_MAX,
44
+ SCENE_WIDTH_MIN,
45
+ useViewerPrefs,
46
+ } from '@terrafirmagreg-team/ui/hooks/viewer/use-viewer-prefs';
47
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
48
+ import type {
49
+ GlbHomeView,
50
+ GlbOrbitControls,
51
+ GlbSceneHandle,
52
+ GlbSessionProps,
53
+ } from '@terrafirmagreg-team/ui/types/glb';
54
+
55
+ class ErrorBoundary extends Component<
56
+ { children?: ReactNode; onError?: (error: unknown) => void },
57
+ { hasError: boolean }
58
+ > {
59
+ state = { hasError: false };
60
+ static getDerivedStateFromError() {
61
+ return { hasError: true };
62
+ }
63
+ componentDidCatch(error: unknown) {
64
+ this.props.onError?.(error);
65
+ }
66
+ render() {
67
+ return this.state.hasError ? null : this.props.children;
68
+ }
69
+ }
70
+
71
+ const GLB_ZOOM_STEP = 1.3;
72
+
73
+ export function GlbScene({
74
+ urls,
75
+ title,
76
+ scale,
77
+ offset,
78
+ bare = false,
79
+ resolveResource,
80
+ }: {
81
+ urls: string[];
82
+ title?: string;
83
+ scale?: number;
84
+ offset?: number;
85
+ bare?: boolean;
86
+ resolveResource?: (
87
+ resourceUrl: string,
88
+ modelUrl: string,
89
+ ) => string | undefined;
90
+ }) {
91
+ const t = useTranslations();
92
+ const {
93
+ autoplay,
94
+ setAutoplay,
95
+ autoplaySpeed,
96
+ setAutoplaySpeed,
97
+ autoRotate,
98
+ setAutoRotate,
99
+ rotateSpeed,
100
+ setRotateSpeed,
101
+ dotGrid,
102
+ setDotGrid,
103
+ sceneWidth,
104
+ setSceneWidth,
105
+ sceneHeight,
106
+ setSceneHeight,
107
+ } = useViewerPrefs();
108
+ const [hovered, setHovered] = useState(false);
109
+ const [selected, setSelected] = useCycleIndex(urls.length, {
110
+ enabled: autoplay,
111
+ paused: hovered,
112
+ intervalMs: autoplaySpeed * 1000,
113
+ });
114
+ const [active, setActive] = useState(false);
115
+ const hostRef = useRef<HTMLDivElement>(null);
116
+ const surfaceRef = useRef<HTMLDivElement>(null);
117
+ const apiRef = useRef<GlbSceneHandle>(null);
118
+ const controlsRef = useRef<GlbOrbitControls>(null);
119
+ const homeRef = useRef<GlbHomeView | null>(null);
120
+ const initializedRef = useRef(false);
121
+ const rendererRef = useRef<WebGLRenderer | null>(null);
122
+ const label = title || t('ui.viewer.label');
123
+ const url = urls[Math.min(selected, urls.length - 1)];
124
+ const [statusEntry, setStatusEntry] = useState<{
125
+ url: string;
126
+ status: 'loading' | 'ready' | 'error';
127
+ }>({ url, status: 'loading' });
128
+ const status = statusEntry.url === url ? statusEntry.status : 'loading';
129
+ const setStatus = useCallback(
130
+ (next: 'loading' | 'ready' | 'error') =>
131
+ setStatusEntry({ url, status: next }),
132
+ [url],
133
+ );
134
+
135
+ const handleReady = useCallback(() => setStatus('ready'), [setStatus]);
136
+ const handleError = useCallback(() => setStatus('error'), [setStatus]);
137
+
138
+ useEffect(() => {
139
+ const host = hostRef.current;
140
+ if (!host) return;
141
+ const io = new IntersectionObserver(
142
+ ([entry]) => {
143
+ if (entry?.isIntersecting) {
144
+ setActive(true);
145
+ return;
146
+ }
147
+ rendererRef.current?.forceContextLoss();
148
+ rendererRef.current = null;
149
+ initializedRef.current = false;
150
+ setStatus('loading');
151
+ setActive(false);
152
+ },
153
+ { rootMargin: '160px', threshold: 0.01 },
154
+ );
155
+ io.observe(host);
156
+ return () => io.disconnect();
157
+ }, [setStatus]);
158
+
159
+ useEffect(() => {
160
+ if (!active || urls.length < 2) return;
161
+ const nextUrl = urls[(selected + 1) % urls.length];
162
+ if (nextUrl)
163
+ useLoader.preload(glbLoaderClass(nextUrl, resolveResource), nextUrl);
164
+ }, [active, selected, urls, resolveResource]);
165
+
166
+ const flip = typeof scale === 'number' && scale < 0;
167
+ const multi = urls.length > 1;
168
+
169
+ return (
170
+ <div
171
+ ref={surfaceRef}
172
+ className={cn(
173
+ 'not-prose overflow-hidden',
174
+ bare
175
+ ? 'relative h-full w-full'
176
+ : 'viewer-surface mx-auto my-3 rounded-xl border border-border',
177
+ )}
178
+ style={bare ? undefined : { width: `${sceneWidth}%` }}
179
+ onPointerEnter={() => setHovered(true)}
180
+ onPointerLeave={() => setHovered(false)}
181
+ onFocus={() => setHovered(true)}
182
+ onBlur={() => setHovered(false)}
183
+ >
184
+ <div
185
+ className={cn(
186
+ 'relative w-full',
187
+ bare ? 'h-full' : 'viewer-scene',
188
+ !bare && dotGrid && 'viewer-dots',
189
+ )}
190
+ style={bare ? undefined : { height: sceneHeight }}
191
+ >
192
+ <div
193
+ ref={hostRef}
194
+ className="absolute inset-0 transition-opacity duration-300"
195
+ style={{ opacity: status === 'ready' ? 1 : 0 }}
196
+ >
197
+ {active ? (
198
+ <Canvas
199
+ flat
200
+ gl={{ antialias: true, alpha: true }}
201
+ dpr={[1, 2]}
202
+ camera={{ fov: 35, position: [4, 3, 6] }}
203
+ role="img"
204
+ aria-label={label}
205
+ onCreated={({ gl }) => {
206
+ rendererRef.current = gl;
207
+ }}
208
+ >
209
+ <ambientLight intensity={0.85} />
210
+ <directionalLight position={[6, 10, 4]} intensity={1.1} />
211
+ <ErrorBoundary key={url} onError={handleError}>
212
+ <Suspense fallback={null}>
213
+ <GlbModel
214
+ url={url}
215
+ flip={flip}
216
+ scale={scale}
217
+ offset={offset}
218
+ controlsRef={controlsRef}
219
+ homeRef={homeRef}
220
+ initializedRef={initializedRef}
221
+ resolveResource={resolveResource}
222
+ onReady={handleReady}
223
+ />
224
+ </Suspense>
225
+ </ErrorBoundary>
226
+ <GlbSceneApi
227
+ ref={apiRef}
228
+ controlsRef={controlsRef}
229
+ homeRef={homeRef}
230
+ />
231
+ <OrbitControls
232
+ makeDefault
233
+ enableDamping
234
+ autoRotate={autoRotate}
235
+ autoRotateSpeed={rotateSpeed}
236
+ ref={controlsRef}
237
+ />
238
+ </Canvas>
239
+ ) : null}
240
+ </div>
241
+ {bare ? null : (
242
+ <ViewerToolbar>
243
+ <ViewerControls
244
+ pager={
245
+ multi
246
+ ? { index: selected, count: urls.length, onSelect: setSelected }
247
+ : undefined
248
+ }
249
+ zoom={{
250
+ onZoomIn: () => apiRef.current?.zoomBy(1 / GLB_ZOOM_STEP),
251
+ onZoomOut: () => apiRef.current?.zoomBy(GLB_ZOOM_STEP),
252
+ onFit: () => apiRef.current?.reset(),
253
+ fitLabel: t('ui.viewer.reset_view'),
254
+ }}
255
+ expand={{ target: surfaceRef }}
256
+ settings={{
257
+ label: t('ui.viewer.settings'),
258
+ sliders: [
259
+ {
260
+ icon: <MoveHorizontal />,
261
+ label: t('ui.viewer.width'),
262
+ min: SCENE_WIDTH_MIN,
263
+ max: SCENE_WIDTH_MAX,
264
+ step: 5,
265
+ format: (value) => `${value}%`,
266
+ value: sceneWidth,
267
+ onChange: setSceneWidth,
268
+ },
269
+ {
270
+ icon: <MoveVertical />,
271
+ label: t('ui.viewer.height'),
272
+ min: SCENE_HEIGHT_MIN,
273
+ max: SCENE_HEIGHT_MAX,
274
+ step: 16,
275
+ format: (value) => `${value}px`,
276
+ value: sceneHeight,
277
+ onChange: setSceneHeight,
278
+ },
279
+ {
280
+ icon: <Gauge />,
281
+ label: t('ui.viewer.rotate_speed'),
282
+ min: ROTATE_MIN,
283
+ max: ROTATE_MAX,
284
+ step: 0.1,
285
+ format: (value) => `${value.toFixed(1)}×`,
286
+ value: rotateSpeed,
287
+ onChange: setRotateSpeed,
288
+ },
289
+ ...(multi
290
+ ? [
291
+ {
292
+ icon: <Timer />,
293
+ label: t('ui.viewer.autoplay_speed'),
294
+ min: AUTOPLAY_SPEED_MIN,
295
+ max: AUTOPLAY_SPEED_MAX,
296
+ step: 0.5,
297
+ disabled: !autoplay,
298
+ format: (value: number) => `${value.toFixed(1)}s`,
299
+ value: autoplaySpeed,
300
+ onChange: setAutoplaySpeed,
301
+ },
302
+ ]
303
+ : []),
304
+ ],
305
+ toggles: [
306
+ {
307
+ icon: <RotateCw />,
308
+ label: t('ui.viewer.spin'),
309
+ checked: autoRotate,
310
+ onChange: setAutoRotate,
311
+ },
312
+ ...(multi
313
+ ? [
314
+ {
315
+ icon: <Play />,
316
+ label: t('ui.viewer.autoplay'),
317
+ checked: autoplay,
318
+ onChange: setAutoplay,
319
+ },
320
+ ]
321
+ : []),
322
+ {
323
+ icon: <Grid3x3 />,
324
+ label: t('ui.viewer.grid'),
325
+ checked: dotGrid,
326
+ onChange: setDotGrid,
327
+ },
328
+ ],
329
+ }}
330
+ />
331
+ </ViewerToolbar>
332
+ )}
333
+ {status === 'loading' ? (
334
+ <p className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
335
+ {t('ui.viewer.loading')}
336
+ </p>
337
+ ) : status === 'error' ? (
338
+ <p className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
339
+ {t('ui.image_unavailable')}
340
+ </p>
341
+ ) : null}
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ export function GlbSession({
348
+ src,
349
+ sources,
350
+ title,
351
+ scale,
352
+ offset,
353
+ bare = false,
354
+ resolveResource,
355
+ }: GlbSessionProps) {
356
+ const urls = useMemo(
357
+ () => (sources?.length ? sources : src ? [src] : []).filter(Boolean),
358
+ [src, sources],
359
+ );
360
+ if (urls.length === 0) return null;
361
+ return (
362
+ <GlbScene
363
+ key={urls.join('\0')}
364
+ urls={urls}
365
+ title={title}
366
+ scale={scale}
367
+ offset={offset}
368
+ bare={bare}
369
+ resolveResource={resolveResource}
370
+ />
371
+ );
372
+ }
@@ -0,0 +1,51 @@
1
+ import dynamic from 'next/dynamic';
2
+ import { Box } from 'lucide-react';
3
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
4
+
5
+ export const GlbViewer = dynamic(
6
+ () =>
7
+ import('@terrafirmagreg-team/ui/components/blocks/media/glb/scene').then(
8
+ (mod) => mod.GlbSession,
9
+ ),
10
+ { loading: () => <GlbPlaceholder /> },
11
+ );
12
+
13
+ export const GlbEmbedViewer = dynamic(
14
+ () =>
15
+ import('@terrafirmagreg-team/ui/components/blocks/media/glb/scene').then(
16
+ (mod) => mod.GlbSession,
17
+ ),
18
+ { loading: () => <GlbPlaceholder bare /> },
19
+ );
20
+
21
+ export function GlbPlaceholder({
22
+ title,
23
+ bare,
24
+ }: {
25
+ title?: string;
26
+ bare?: boolean;
27
+ }) {
28
+ return (
29
+ <div
30
+ className={cn(
31
+ 'not-prose overflow-hidden',
32
+ bare
33
+ ? 'h-full w-full'
34
+ : 'viewer-surface my-3 rounded-xl border border-border',
35
+ )}
36
+ aria-label={title}
37
+ >
38
+ <div
39
+ className={cn(
40
+ 'flex items-center justify-center',
41
+ bare ? 'h-full w-full' : 'viewer-scene h-72 w-full',
42
+ )}
43
+ aria-hidden
44
+ >
45
+ {bare ? null : (
46
+ <Box className="size-10 text-muted-foreground/40" />
47
+ )}
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,135 @@
1
+ 'use client';
2
+
3
+ import { useState, type CSSProperties } from 'react';
4
+ import { useAsyncEffect, useAsyncResult, useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
5
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
6
+ import { cropOpaqueImageDataUrl } from '@terrafirmagreg-team/ui/lib/media/crop-opaque';
7
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
8
+
9
+ export function MediaImage({
10
+ src,
11
+ alt,
12
+ eager = false,
13
+ className,
14
+ style,
15
+ href,
16
+ missing = 'box',
17
+ crop = false,
18
+ width,
19
+ height,
20
+ align,
21
+ pixelated,
22
+ onError,
23
+ }: {
24
+ src?: string | Promise<string | undefined> | null;
25
+ alt?: string;
26
+ eager?: boolean;
27
+ className?: string;
28
+ style?: CSSProperties;
29
+ href?: string | Promise<string | undefined>;
30
+ missing?: 'box' | 'hide';
31
+ crop?: boolean;
32
+ width?: number;
33
+ height?: number;
34
+ align?: 'center' | string;
35
+ pixelated?: boolean;
36
+ onError?: () => void;
37
+ }) {
38
+ const t = useTranslations();
39
+ const asyncSrc = src != null && typeof src !== 'string';
40
+ const { value: resolved, pending } = useAsyncResult(
41
+ asyncSrc ? () => src : false,
42
+ [src],
43
+ );
44
+ const url = asyncSrc ? resolved : (src ?? undefined);
45
+ const resolvedHref = useAsyncValue(
46
+ href === undefined
47
+ ? false
48
+ : typeof href === 'string'
49
+ ? () => Promise.resolve(href)
50
+ : () => href,
51
+ [href],
52
+ );
53
+ const sourceKey = url ?? '';
54
+ const [state, setState] = useState<{
55
+ key: string;
56
+ cropped?: string;
57
+ failed?: boolean;
58
+ }>({ key: sourceKey });
59
+ const failed = state.key === sourceKey && state.failed === true;
60
+ const cropped = state.key === sourceKey ? state.cropped : undefined;
61
+
62
+ useAsyncEffect(
63
+ url && crop
64
+ ? (isCancelled) => {
65
+ const img = new Image();
66
+ img.onload = () => {
67
+ if (!isCancelled()) {
68
+ setState({
69
+ key: url,
70
+ cropped: cropOpaqueImageDataUrl(img),
71
+ });
72
+ }
73
+ };
74
+ img.onerror = () => {
75
+ if (!isCancelled()) {
76
+ setState({ key: url, failed: true });
77
+ onError?.();
78
+ }
79
+ };
80
+ img.src = url;
81
+ }
82
+ : false,
83
+ [url, crop],
84
+ );
85
+
86
+ if (asyncSrc && pending) return null;
87
+ if (!url || failed) {
88
+ if (missing === 'hide') return null;
89
+ return (
90
+ <div
91
+ role="img"
92
+ aria-label={alt || t('ui.image_unavailable')}
93
+ className={cn(
94
+ 'flex min-h-24 w-full items-center justify-center rounded-md border border-border bg-secondary text-sm text-muted-foreground',
95
+ className,
96
+ )}
97
+ >
98
+ {t('ui.image_unavailable')}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ const img = (
104
+ // eslint-disable-next-line @next/next/no-img-element
105
+ <img
106
+ src={cropped ?? url}
107
+ alt={alt}
108
+ width={width}
109
+ height={height}
110
+ loading={eager ? 'eager' : 'lazy'}
111
+ decoding={eager ? 'sync' : 'async'}
112
+ fetchPriority={eager ? 'high' : 'auto'}
113
+ className={cn(crop ? 'max-w-full object-contain' : undefined, className)}
114
+ style={{
115
+ ...style,
116
+ ...(pixelated ? { imageRendering: 'pixelated' } : null),
117
+ }}
118
+ onError={() => {
119
+ setState({ key: sourceKey, failed: true });
120
+ onError?.();
121
+ }}
122
+ />
123
+ );
124
+ if (!resolvedHref) return img;
125
+ return (
126
+ <a
127
+ href={resolvedHref}
128
+ target="_blank"
129
+ rel="noreferrer"
130
+ className={align === 'center' ? 'mx-auto block w-fit' : 'contents'}
131
+ >
132
+ {img}
133
+ </a>
134
+ );
135
+ }