@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,256 @@
1
+ 'use client';
2
+ import { useContext, useState } from 'react';
3
+ import type { ComponentProps } from 'react';
4
+ import { useUiComponents } from '@terrafirmagreg-team/ui/provider';
5
+ import { Globe } from 'lucide-react';
6
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
7
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
8
+ import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
9
+ import {
10
+ HoverCard,
11
+ HoverCardContent,
12
+ HoverCardTrigger,
13
+ } from '@terrafirmagreg-team/ui/components/ui/hover-card';
14
+ import {
15
+ loadExternalMeta,
16
+ useExternalMeta,
17
+ } from '@terrafirmagreg-team/ui/hooks/use-external-meta';
18
+ import {
19
+ loadPagePreview,
20
+ usePagePreview,
21
+ } from '@terrafirmagreg-team/ui/hooks/page/use-page-preview';
22
+ import {
23
+ DEFAULT_EXTERNAL_FAVICON,
24
+ uiConfig,
25
+ } from '@terrafirmagreg-team/ui/lib/config';
26
+ import { ResolvedLangContext } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
27
+ import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
28
+
29
+ type LinkPreviewVariant = 'raw' | 'text' | 'chip';
30
+
31
+ function FaviconIcon({ href }: { href: string }) {
32
+ const [failed, setFailed] = useState(false);
33
+ let host = '';
34
+ try {
35
+ host = new URL(href).hostname;
36
+ } catch {
37
+ return <Globe className="size-4 shrink-0 text-muted-foreground" />;
38
+ }
39
+ if (failed || !host) {
40
+ return <Globe className="size-4 shrink-0 text-muted-foreground" />;
41
+ }
42
+ return (
43
+ // eslint-disable-next-line @next/next/no-img-element
44
+ <img
45
+ src={(uiConfig().externalFavicon ?? DEFAULT_EXTERNAL_FAVICON).replace('{host}', host)}
46
+ alt=""
47
+ className="size-4 shrink-0 rounded-sm"
48
+ onError={() => setFailed(true)}
49
+ />
50
+ );
51
+ }
52
+
53
+ function externalHost(href: string): string {
54
+ try {
55
+ return new URL(href).hostname || href;
56
+ } catch {
57
+ return href;
58
+ }
59
+ }
60
+
61
+ function ExternalAnchor({
62
+ href,
63
+ className,
64
+ children,
65
+ ...props
66
+ }: ComponentProps<'a'>) {
67
+ const [open, setOpen] = useState(false);
68
+ const meta = useExternalMeta(href ?? '', open);
69
+ const host = meta?.host || externalHost(href ?? '');
70
+ const title = meta?.title || host;
71
+ const detail = [host && host !== title ? host : '', meta?.description]
72
+ .filter(Boolean)
73
+ .join(' — ');
74
+ return (
75
+ <HoverCard open={open} onOpenChange={setOpen}>
76
+ <HoverCardTrigger
77
+ render={
78
+ <a
79
+ href={href}
80
+ className={className}
81
+ target="_blank"
82
+ rel="noreferrer"
83
+ onMouseEnter={() => void loadExternalMeta(href ?? '')}
84
+ {...props}
85
+ />
86
+ }
87
+ >
88
+ {children}
89
+ </HoverCardTrigger>
90
+ <HoverCardContent className="w-fit max-w-[min(18rem,90vw)]">
91
+ <div className="flex w-full min-w-0 items-center gap-2 font-medium">
92
+ <FaviconIcon href={href ?? ''} />
93
+ {meta ? (
94
+ <span className="truncate">{title}</span>
95
+ ) : (
96
+ <Skeleton className="h-4 w-32" />
97
+ )}
98
+ </div>
99
+ {meta === null ? (
100
+ <Skeleton className="mt-1 h-3 w-40" />
101
+ ) : detail ? (
102
+ <p className="text-sm text-muted-foreground line-clamp-3">
103
+ {detail}
104
+ </p>
105
+ ) : null}
106
+ </HoverCardContent>
107
+ </HoverCard>
108
+ );
109
+ }
110
+
111
+ function InternalAnchor({
112
+ href,
113
+ className,
114
+ children,
115
+ ...props
116
+ }: ComponentProps<'a'>) {
117
+ const { Link } = useUiComponents();
118
+ const lang = useContext(ResolvedLangContext)?.lang;
119
+ const [open, setOpen] = useState(false);
120
+ const resolved = usePagePreview(href ?? '', open, lang);
121
+ const PreviewIconCmp = uiConfig().pagePreviewIcon;
122
+ return (
123
+ <HoverCard open={open} onOpenChange={setOpen}>
124
+ <HoverCardTrigger
125
+ render={
126
+ <Link
127
+ href={href ?? ''}
128
+ className={className}
129
+ onMouseEnter={() => void loadPagePreview(href ?? '', lang)}
130
+ {...props}
131
+ />
132
+ }
133
+ >
134
+ {children}
135
+ </HoverCardTrigger>
136
+ {resolved === undefined ? null : (
137
+ <HoverCardContent className="w-fit max-w-[min(18rem,90vw)]">
138
+ {resolved === null ? (
139
+ <>
140
+ <Skeleton className="h-4 w-32" />
141
+ <Skeleton className="mt-1 h-3 w-40" />
142
+ </>
143
+ ) : (
144
+ <>
145
+ <div className="flex items-center gap-2 font-medium">
146
+ {resolved.icon && PreviewIconCmp ? (
147
+ <PreviewIconCmp icon={resolved.icon} />
148
+ ) : null}
149
+ <span className="truncate">{resolved.title}</span>
150
+ </div>
151
+ {resolved.description ? (
152
+ <p className="text-sm text-muted-foreground line-clamp-3">
153
+ {resolved.description}
154
+ </p>
155
+ ) : null}
156
+ </>
157
+ )}
158
+ </HoverCardContent>
159
+ )}
160
+ </HoverCard>
161
+ );
162
+ }
163
+
164
+ export function LinkPreview({
165
+ preview,
166
+ href,
167
+ className,
168
+ variant = 'raw',
169
+ size = 'md',
170
+ solid,
171
+ children,
172
+ ...props
173
+ }: ComponentProps<'a'> & {
174
+ preview?: PagePreviewData;
175
+ variant?: LinkPreviewVariant;
176
+ size?: 'sm' | 'md';
177
+ solid?: boolean;
178
+ }) {
179
+ const { Link } = useUiComponents();
180
+ const PreviewIconCmp = uiConfig().pagePreviewIcon;
181
+ const external = Boolean(href && /^(https?:|mailto:|\/\/)/i.test(href));
182
+
183
+ if (variant === 'chip') {
184
+ const chipClass = cn(
185
+ buttonVariants({ variant: solid ? 'secondary' : 'outline' }),
186
+ size === 'sm' && 'px-2 py-1.5 text-xs',
187
+ className,
188
+ );
189
+ if (external) {
190
+ return (
191
+ <a
192
+ href={href}
193
+ target="_blank"
194
+ rel="noreferrer"
195
+ className={chipClass}
196
+ {...props}
197
+ >
198
+ {children}
199
+ </a>
200
+ );
201
+ }
202
+ return (
203
+ <Link href={href ?? ''} className={chipClass} {...props}>
204
+ {children}
205
+ </Link>
206
+ );
207
+ }
208
+
209
+ const merged = cn(
210
+ variant === 'text' && 'text-primary underline underline-offset-4',
211
+ className,
212
+ );
213
+
214
+ if (!href) {
215
+ return (
216
+ <a className={merged} {...props}>
217
+ {children}
218
+ </a>
219
+ );
220
+ }
221
+ if (external) {
222
+ return (
223
+ <ExternalAnchor href={href} className={merged} {...props}>
224
+ {children}
225
+ </ExternalAnchor>
226
+ );
227
+ }
228
+ if (!preview?.title) {
229
+ return (
230
+ <InternalAnchor href={href} className={merged} {...props}>
231
+ {children}
232
+ </InternalAnchor>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <HoverCard>
238
+ <HoverCardTrigger render={<Link href={href} className={merged} {...props} />}>
239
+ {children}
240
+ </HoverCardTrigger>
241
+ <HoverCardContent className="w-fit max-w-[min(18rem,90vw)]">
242
+ <div className="flex items-center gap-2 font-medium">
243
+ {preview.icon && PreviewIconCmp ? (
244
+ <PreviewIconCmp icon={preview.icon} />
245
+ ) : null}
246
+ <span className="truncate">{preview.title}</span>
247
+ </div>
248
+ {preview.description ? (
249
+ <p className="text-sm text-muted-foreground line-clamp-3">
250
+ {preview.description}
251
+ </p>
252
+ ) : null}
253
+ </HoverCardContent>
254
+ </HoverCard>
255
+ );
256
+ }
@@ -0,0 +1,91 @@
1
+ import { Check } from 'lucide-react';
2
+ import Link from 'next/link';
3
+ import type { ComponentProps, ReactNode } from 'react';
4
+ import {
5
+ Item,
6
+ ItemActions,
7
+ ItemContent,
8
+ ItemDescription,
9
+ ItemMedia,
10
+ ItemTitle,
11
+ } from '@terrafirmagreg-team/ui/components/ui/item';
12
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
13
+
14
+ type ItemProps = ComponentProps<typeof Item>;
15
+
16
+ export function ItemRow({
17
+ layout = 'list',
18
+ href,
19
+ target,
20
+ rel,
21
+ onClick,
22
+ media,
23
+ mediaClassName,
24
+ title,
25
+ titleClassName,
26
+ description,
27
+ overline,
28
+ code,
29
+ selected,
30
+ trailing,
31
+ size = 'xs',
32
+ variant = 'default',
33
+ align = 'center',
34
+ className,
35
+ }: {
36
+ layout?: 'list' | 'header';
37
+ href?: string;
38
+ target?: string;
39
+ rel?: string;
40
+ onClick?: () => void;
41
+ media?: ReactNode;
42
+ mediaClassName?: string;
43
+ title: ReactNode;
44
+ titleClassName?: string;
45
+ description?: ReactNode;
46
+ overline?: ReactNode;
47
+ code?: ReactNode;
48
+ selected?: boolean;
49
+ trailing?: ReactNode;
50
+ size?: ItemProps['size'];
51
+ variant?: ItemProps['variant'];
52
+ align?: 'center' | 'start';
53
+ className?: string;
54
+ }) {
55
+ if (layout === 'header') {
56
+ return (
57
+ <div className={cn('flex gap-3', align === 'center' ? 'items-center' : 'items-start', className)}>
58
+ {media}
59
+ <div className="min-w-0 flex-1">
60
+ {overline ? <p className="text-xs text-muted-foreground">{overline}</p> : null}
61
+ <p className={cn('text-sm font-medium', titleClassName)}>{title}</p>
62
+ {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
63
+ {code ? <code className="text-xs text-muted-foreground">{code}</code> : null}
64
+ </div>
65
+ {trailing}
66
+ </div>
67
+ );
68
+ }
69
+ return (
70
+ <Item
71
+ size={size}
72
+ variant={variant}
73
+ className={className}
74
+ {...(href
75
+ ? { render: <Link href={href} target={target} rel={rel} onClick={onClick} className="cursor-pointer hover:bg-accent hover:text-accent-foreground" /> }
76
+ : { onClick, className: cn(className, onClick && 'cursor-pointer hover:bg-accent hover:text-accent-foreground') })}
77
+ >
78
+ {media ? <ItemMedia className={mediaClassName}>{media}</ItemMedia> : null}
79
+ <ItemContent>
80
+ <ItemTitle className={cn('truncate', titleClassName)}>{title}</ItemTitle>
81
+ {description !== undefined ? <ItemDescription className="min-h-4 truncate">{description}</ItemDescription> : null}
82
+ </ItemContent>
83
+ {trailing || selected !== undefined ? (
84
+ <ItemActions>
85
+ {trailing}
86
+ {selected !== undefined ? <Check className={cn('size-3.5 text-primary', !selected && 'invisible')} /> : null}
87
+ </ItemActions>
88
+ ) : null}
89
+ </Item>
90
+ );
91
+ }
@@ -0,0 +1,243 @@
1
+ 'use client';
2
+
3
+ import { useLoader, useThree } from '@react-three/fiber';
4
+ import { GLTFLoader } from 'three-stdlib';
5
+ import {
6
+ forwardRef,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ type RefObject,
12
+ useRef,
13
+ } from 'react';
14
+ import {
15
+ Box3,
16
+ type Group,
17
+ LinearMipmapLinearFilter,
18
+ LoadingManager,
19
+ type Material,
20
+ MathUtils,
21
+ type Mesh,
22
+ type MeshStandardMaterial,
23
+ NearestFilter,
24
+ type PerspectiveCamera,
25
+ SRGBColorSpace,
26
+ Texture,
27
+ Vector3,
28
+ } from 'three';
29
+ import type {
30
+ GlbHomeView,
31
+ GlbOrbitControls,
32
+ GlbSceneHandle,
33
+ } from '@terrafirmagreg-team/ui/types/glb';
34
+
35
+ const GLB_ZOOM_MIN_FACTOR = 0.4;
36
+ const GLB_ZOOM_MAX_FACTOR = 3;
37
+
38
+ type ResourceResolver = (
39
+ resourceUrl: string,
40
+ modelUrl: string,
41
+ ) => string | undefined;
42
+
43
+ const glbLoaderClasses = new WeakMap<
44
+ ResourceResolver,
45
+ Map<string, typeof GLTFLoader>
46
+ >();
47
+
48
+ export function glbLoaderClass(
49
+ url: string,
50
+ resolveResource?: ResourceResolver,
51
+ ): typeof GLTFLoader {
52
+ if (!resolveResource) return GLTFLoader;
53
+ let byUrl = glbLoaderClasses.get(resolveResource);
54
+ if (!byUrl) {
55
+ byUrl = new Map();
56
+ glbLoaderClasses.set(resolveResource, byUrl);
57
+ }
58
+ let cls = byUrl.get(url);
59
+ if (!cls) {
60
+ const resolve = resolveResource;
61
+ cls = class extends GLTFLoader {
62
+ constructor() {
63
+ const manager = new LoadingManager();
64
+ manager.setURLModifier(
65
+ (resourceUrl) => resolve(resourceUrl, url) ?? resourceUrl,
66
+ );
67
+ super(manager);
68
+ }
69
+ };
70
+ byUrl.set(url, cls);
71
+ }
72
+ return cls;
73
+ }
74
+
75
+ export function GlbModel({
76
+ url,
77
+ flip,
78
+ scale,
79
+ offset,
80
+ controlsRef,
81
+ homeRef,
82
+ initializedRef,
83
+ resolveResource,
84
+ onReady,
85
+ }: {
86
+ url: string;
87
+ flip: boolean;
88
+ scale?: number;
89
+ offset?: number;
90
+ controlsRef: RefObject<GlbOrbitControls | null>;
91
+ homeRef: RefObject<GlbHomeView | null>;
92
+ initializedRef: RefObject<boolean>;
93
+ resolveResource?: ResourceResolver;
94
+ onReady: () => void;
95
+ }) {
96
+ const { scene } = useLoader(
97
+ glbLoaderClass(url, resolveResource),
98
+ url,
99
+ ) as { scene: Group };
100
+ const model = useMemo(() => {
101
+ const clone = scene.clone(true);
102
+ clone.traverse((object) => {
103
+ const mesh = object as Mesh;
104
+ const materials = Array.isArray(mesh.material)
105
+ ? mesh.material
106
+ : mesh.material
107
+ ? [mesh.material]
108
+ : [];
109
+ for (const material of materials) {
110
+ const std = material as MeshStandardMaterial;
111
+ const map = std.map;
112
+ if (map) {
113
+ map.colorSpace = SRGBColorSpace;
114
+ map.magFilter = NearestFilter;
115
+ map.minFilter = LinearMipmapLinearFilter;
116
+ map.anisotropy = 4;
117
+ map.needsUpdate = true;
118
+ }
119
+ const color = mesh.geometry?.getAttribute('color');
120
+ if (std.vertexColors && color) {
121
+ let uniformWhite = true;
122
+ for (let i = 0; i < color.count * color.itemSize; i++) {
123
+ if ((color.array as ArrayLike<number>)[i] < 0.99) {
124
+ uniformWhite = false;
125
+ break;
126
+ }
127
+ }
128
+ if (uniformWhite) {
129
+ std.vertexColors = false;
130
+ std.needsUpdate = true;
131
+ }
132
+ }
133
+ }
134
+ });
135
+ return clone;
136
+ }, [scene]);
137
+ const modelRef = useRef<Group>(null);
138
+
139
+ useEffect(() => {
140
+ return () => {
141
+ model.traverse((object) => {
142
+ const mesh = object as Mesh;
143
+ mesh.geometry?.dispose?.();
144
+ const material = mesh.material as Material | Material[] | undefined;
145
+ for (const entry of Array.isArray(material)
146
+ ? material
147
+ : material
148
+ ? [material]
149
+ : []) {
150
+ for (const value of Object.values(entry)) {
151
+ if (value instanceof Texture) value.dispose();
152
+ }
153
+ entry.dispose();
154
+ }
155
+ });
156
+ };
157
+ }, [model]);
158
+ const controlsReady = useThree((state) => state.controls != null);
159
+
160
+ useLayoutEffect(() => {
161
+ const controls = controlsRef.current;
162
+ const root = modelRef.current;
163
+ if (!controls || !root) return;
164
+ const camera = controls.object as PerspectiveCamera;
165
+ const box = new Box3().setFromObject(root);
166
+ const size = box.getSize(new Vector3()).length() || 1;
167
+ const center = box.getCenter(new Vector3());
168
+ const orbit = camera.position.clone().sub(controls.target);
169
+ controls.minDistance = size * GLB_ZOOM_MIN_FACTOR;
170
+ controls.maxDistance = size * GLB_ZOOM_MAX_FACTOR;
171
+ controls.target.copy(center);
172
+ if (initializedRef.current && orbit.lengthSq() > 0) {
173
+ camera.position.copy(center).add(orbit);
174
+ } else {
175
+ camera.position.copy(center).add(new Vector3(size, size * 0.7, size));
176
+ }
177
+ camera.near = size / 50;
178
+ camera.far = size * 15;
179
+ camera.updateProjectionMatrix();
180
+ controls.update();
181
+ initializedRef.current = true;
182
+ homeRef.current = {
183
+ position: camera.position.toArray() as [number, number, number],
184
+ target: controls.target.toArray() as [number, number, number],
185
+ };
186
+ onReady();
187
+ }, [controlsReady, controlsRef, homeRef, initializedRef, onReady, url]);
188
+
189
+ return (
190
+ <group ref={modelRef}>
191
+ <primitive
192
+ object={model}
193
+ rotation-x={flip ? Math.PI : 0}
194
+ scale={
195
+ typeof scale === 'number' && Number.isFinite(scale) && scale !== 0
196
+ ? Math.abs(scale)
197
+ : 1
198
+ }
199
+ position-y={
200
+ typeof offset === 'number' && Number.isFinite(offset) ? offset : 0
201
+ }
202
+ />
203
+ </group>
204
+ );
205
+ }
206
+
207
+ export const GlbSceneApi = forwardRef<
208
+ GlbSceneHandle,
209
+ {
210
+ controlsRef: RefObject<GlbOrbitControls | null>;
211
+ homeRef: RefObject<GlbHomeView | null>;
212
+ }
213
+ >(function GlbSceneApi({ controlsRef, homeRef }, ref) {
214
+ useImperativeHandle(
215
+ ref,
216
+ () => ({
217
+ zoomBy(factor: number) {
218
+ const controls = controlsRef.current;
219
+ if (!controls) return;
220
+ const camera = controls.object;
221
+ const offset = camera.position.clone().sub(controls.target);
222
+ const length = MathUtils.clamp(
223
+ offset.length() * factor,
224
+ controls.minDistance,
225
+ controls.maxDistance,
226
+ );
227
+ camera.position.copy(controls.target).add(offset.setLength(length));
228
+ controls.update();
229
+ },
230
+ reset() {
231
+ const controls = controlsRef.current;
232
+ const home = homeRef.current;
233
+ if (!controls || !home) return;
234
+ const camera = controls.object;
235
+ camera.position.set(...home.position);
236
+ controls.target.set(...home.target);
237
+ controls.update();
238
+ },
239
+ }),
240
+ [controlsRef, homeRef],
241
+ );
242
+ return null;
243
+ });