@terrafirmagreg-team/ui 0.0.0-stage → 1.2.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 (225) hide show
  1. package/package.json +93 -3
  2. package/src/components/blocks/button/icon-button.tsx +49 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +242 -0
  4. package/src/components/blocks/canvas/flow-canvas.tsx +181 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +33 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +244 -0
  7. package/src/components/blocks/fit-box.tsx +26 -0
  8. package/src/components/blocks/git/github-info.tsx +78 -0
  9. package/src/components/blocks/lang/content.tsx +52 -0
  10. package/src/components/blocks/lang/flag.tsx +25 -0
  11. package/src/components/blocks/lang/link.tsx +23 -0
  12. package/src/components/blocks/lang/redirect.tsx +48 -0
  13. package/src/components/blocks/lang/switcher.tsx +54 -0
  14. package/src/components/blocks/lang/untranslated-callout.tsx +29 -0
  15. package/src/components/blocks/lang/untranslated-chip.tsx +53 -0
  16. package/src/components/blocks/link/link-preview.tsx +257 -0
  17. package/src/components/blocks/list/row.tsx +92 -0
  18. package/src/components/blocks/media/glb/model.tsx +244 -0
  19. package/src/components/blocks/media/glb/scene.tsx +373 -0
  20. package/src/components/blocks/media/glb/viewer.tsx +52 -0
  21. package/src/components/blocks/media/image.tsx +136 -0
  22. package/src/components/blocks/media/slideshow.tsx +178 -0
  23. package/src/components/blocks/nav/breadcrumb.tsx +51 -0
  24. package/src/components/blocks/nav/copy-menu.tsx +37 -0
  25. package/src/components/blocks/nav/link.tsx +86 -0
  26. package/src/components/blocks/nav/site-menu.tsx +78 -0
  27. package/src/components/blocks/nav/site-title.tsx +61 -0
  28. package/src/components/blocks/nav/switcher.tsx +206 -0
  29. package/src/components/blocks/page/empty-state.tsx +32 -0
  30. package/src/components/blocks/page/heading.tsx +54 -0
  31. package/src/components/blocks/page/pager.tsx +220 -0
  32. package/src/components/blocks/readability/fields.tsx +250 -0
  33. package/src/components/blocks/readability/menu.tsx +247 -0
  34. package/src/components/blocks/readability/track.tsx +165 -0
  35. package/src/components/blocks/search/command.tsx +80 -0
  36. package/src/components/blocks/search/dialog.tsx +724 -0
  37. package/src/components/blocks/search/provider.tsx +118 -0
  38. package/src/components/blocks/sidebar.tsx +406 -0
  39. package/src/components/blocks/sprite.tsx +231 -0
  40. package/src/components/blocks/text/format-text.tsx +110 -0
  41. package/src/components/blocks/theme/toggle.tsx +75 -0
  42. package/src/components/blocks/tooltip/hint.tsx +28 -0
  43. package/src/components/blocks/tooltip/lazy.tsx +41 -0
  44. package/src/components/blocks/viewer/controls.tsx +284 -0
  45. package/src/components/blocks/viewer/float-bar.tsx +51 -0
  46. package/src/components/layouts/layout.tsx +94 -0
  47. package/src/components/layouts/left-sidebar/content.tsx +63 -0
  48. package/src/components/layouts/left-sidebar/context.tsx +55 -0
  49. package/src/components/layouts/left-sidebar/expand.tsx +23 -0
  50. package/src/components/layouts/left-sidebar/footer.tsx +111 -0
  51. package/src/components/layouts/left-sidebar/index.ts +29 -0
  52. package/src/components/layouts/left-sidebar/items.tsx +103 -0
  53. package/src/components/layouts/left-sidebar/search.tsx +75 -0
  54. package/src/components/layouts/left-sidebar/sidebar.tsx +189 -0
  55. package/src/components/layouts/left-sidebar/tree.tsx +157 -0
  56. package/src/components/layouts/right-sidebar.tsx +96 -0
  57. package/src/components/layouts/tree-context.tsx +42 -0
  58. package/src/components/mdx/accordion.tsx +185 -0
  59. package/src/components/mdx/banner.tsx +157 -0
  60. package/src/components/mdx/callout.tsx +97 -0
  61. package/src/components/mdx/card.tsx +56 -0
  62. package/src/components/mdx/codeblock.tsx +250 -0
  63. package/src/components/mdx/collapsible.tsx +33 -0
  64. package/src/components/mdx/files.tsx +63 -0
  65. package/src/components/mdx/glow-header.tsx +54 -0
  66. package/src/components/mdx/gradient-text.tsx +28 -0
  67. package/src/components/mdx/icon.tsx +35 -0
  68. package/src/components/mdx/image-zoom.tsx +315 -0
  69. package/src/components/mdx/math.tsx +30 -0
  70. package/src/components/mdx/mermaid.tsx +23 -0
  71. package/src/components/mdx/multiblock.tsx +19 -0
  72. package/src/components/mdx/preview.tsx +101 -0
  73. package/src/components/mdx/steps.tsx +10 -0
  74. package/src/components/mdx/table.tsx +14 -0
  75. package/src/components/mdx/tabs.tsx +354 -0
  76. package/src/components/mdx/type-table.tsx +171 -0
  77. package/src/components/provider.tsx +139 -0
  78. package/src/components/ui/accordion.tsx +71 -0
  79. package/src/components/ui/alert-dialog.tsx +187 -0
  80. package/src/components/ui/alert.tsx +75 -0
  81. package/src/components/ui/aspect-ratio.tsx +22 -0
  82. package/src/components/ui/attachment.tsx +207 -0
  83. package/src/components/ui/avatar.tsx +108 -0
  84. package/src/components/ui/badge.tsx +51 -0
  85. package/src/components/ui/breadcrumb.tsx +124 -0
  86. package/src/components/ui/bubble.tsx +127 -0
  87. package/src/components/ui/button-group.tsx +87 -0
  88. package/src/components/ui/button.tsx +57 -0
  89. package/src/components/ui/calendar.tsx +221 -0
  90. package/src/components/ui/card.tsx +102 -0
  91. package/src/components/ui/carousel.tsx +242 -0
  92. package/src/components/ui/chart.tsx +371 -0
  93. package/src/components/ui/checkbox.tsx +28 -0
  94. package/src/components/ui/collapsible.tsx +21 -0
  95. package/src/components/ui/combobox.tsx +297 -0
  96. package/src/components/ui/command.tsx +198 -0
  97. package/src/components/ui/context-menu.tsx +270 -0
  98. package/src/components/ui/dialog.tsx +160 -0
  99. package/src/components/ui/direction.tsx +6 -0
  100. package/src/components/ui/drawer.tsx +227 -0
  101. package/src/components/ui/dropdown-menu.tsx +269 -0
  102. package/src/components/ui/empty.tsx +103 -0
  103. package/src/components/ui/field.tsx +238 -0
  104. package/src/components/ui/hover-card.tsx +50 -0
  105. package/src/components/ui/input-group.tsx +158 -0
  106. package/src/components/ui/input-otp.tsx +86 -0
  107. package/src/components/ui/input.tsx +19 -0
  108. package/src/components/ui/item.tsx +201 -0
  109. package/src/components/ui/kbd.tsx +26 -0
  110. package/src/components/ui/label.tsx +19 -0
  111. package/src/components/ui/marker.tsx +70 -0
  112. package/src/components/ui/menubar.tsx +280 -0
  113. package/src/components/ui/message-scroller.tsx +131 -0
  114. package/src/components/ui/message.tsx +91 -0
  115. package/src/components/ui/native-select.tsx +60 -0
  116. package/src/components/ui/navigation-menu.tsx +167 -0
  117. package/src/components/ui/pagination.tsx +130 -0
  118. package/src/components/ui/popover.tsx +91 -0
  119. package/src/components/ui/progress.tsx +82 -0
  120. package/src/components/ui/questionnaire.tsx +324 -0
  121. package/src/components/ui/radio-group.tsx +37 -0
  122. package/src/components/ui/resizable.tsx +49 -0
  123. package/src/components/ui/scroll-area.tsx +54 -0
  124. package/src/components/ui/select.tsx +200 -0
  125. package/src/components/ui/separator.tsx +24 -0
  126. package/src/components/ui/sheet.tsx +138 -0
  127. package/src/components/ui/sidebar.tsx +730 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +51 -0
  130. package/src/components/ui/spinner.tsx +10 -0
  131. package/src/components/ui/switch.tsx +31 -0
  132. package/src/components/ui/table.tsx +115 -0
  133. package/src/components/ui/tabs.tsx +81 -0
  134. package/src/components/ui/textarea.tsx +17 -0
  135. package/src/components/ui/toast.tsx +234 -0
  136. package/src/components/ui/toggle-group.tsx +89 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +65 -0
  139. package/src/hooks/canvas/use-flow-hover.ts +19 -0
  140. package/src/hooks/canvas/use-flow-viewport-persist.ts +34 -0
  141. package/src/hooks/page/use-fit-pages.ts +92 -0
  142. package/src/hooks/page/use-location-search.ts +31 -0
  143. package/src/hooks/page/use-page-preview.ts +41 -0
  144. package/src/hooks/page/use-paged-list.ts +21 -0
  145. package/src/hooks/readability/use-readability.ts +87 -0
  146. package/src/hooks/search/use-dialog.ts +42 -0
  147. package/src/hooks/search/use-highlight-query.ts +58 -0
  148. package/src/hooks/search/use-provider.ts +26 -0
  149. package/src/hooks/use-async-value.ts +60 -0
  150. package/src/hooks/use-close-on-scroll.ts +24 -0
  151. package/src/hooks/use-copy-button.ts +47 -0
  152. package/src/hooks/use-cycle-index.ts +76 -0
  153. package/src/hooks/use-document-title.ts +10 -0
  154. package/src/hooks/use-element-size.ts +36 -0
  155. package/src/hooks/use-external-meta.ts +133 -0
  156. package/src/hooks/use-hot-key.ts +34 -0
  157. package/src/hooks/use-hover-follow.ts +55 -0
  158. package/src/hooks/use-in-view.ts +31 -0
  159. package/src/hooks/use-lang.ts +9 -0
  160. package/src/hooks/use-last-value.ts +16 -0
  161. package/src/hooks/use-layout.ts +19 -0
  162. package/src/hooks/use-marquee.ts +52 -0
  163. package/src/hooks/use-media-query.ts +15 -0
  164. package/src/hooks/use-mobile.ts +20 -0
  165. package/src/hooks/use-modifier-keys.ts +32 -0
  166. package/src/hooks/use-on-change.ts +36 -0
  167. package/src/hooks/use-pane-keys.ts +29 -0
  168. package/src/hooks/use-sidebar.ts +64 -0
  169. package/src/hooks/use-swipe.ts +27 -0
  170. package/src/hooks/use-tree.ts +100 -0
  171. package/src/hooks/viewer/use-in-float-bar.ts +7 -0
  172. package/src/hooks/viewer/use-viewer-prefs.ts +78 -0
  173. package/src/lib/canvas/edge.ts +202 -0
  174. package/src/lib/canvas/geometry.ts +54 -0
  175. package/src/lib/canvas/state.ts +48 -0
  176. package/src/lib/canvas/viewport.ts +38 -0
  177. package/src/lib/canvas/xyflow.ts +2 -0
  178. package/src/lib/config.ts +106 -0
  179. package/src/lib/mdx/callout.ts +21 -0
  180. package/src/lib/mdx/gradient.ts +16 -0
  181. package/src/lib/mdx/mermaid.ts +15 -0
  182. package/src/lib/media/crop-opaque.ts +77 -0
  183. package/src/lib/media/nearby-indexes.ts +7 -0
  184. package/src/lib/media/trim-transparent.ts +118 -0
  185. package/src/lib/readability/config.ts +11 -0
  186. package/src/lib/readability/spotlight.ts +187 -0
  187. package/src/lib/readability/store.ts +147 -0
  188. package/src/lib/tree.ts +90 -0
  189. package/src/lib/utils/cn.ts +2 -0
  190. package/src/lib/utils/format.ts +22 -0
  191. package/src/lib/utils/highlight-style.ts +10 -0
  192. package/src/lib/utils/history.ts +10 -0
  193. package/src/lib/utils/merge-refs.ts +14 -0
  194. package/src/lib/utils/nav-active.ts +59 -0
  195. package/src/lib/utils/stored-value.ts +137 -0
  196. package/src/lib/utils/urls.ts +18 -0
  197. package/src/locales/en_us.json +110 -0
  198. package/src/locales/pl_pl.json +33 -0
  199. package/src/locales/ru_ru.json +110 -0
  200. package/src/locales/uk_ua.json +33 -0
  201. package/src/locales/zh_cn.json +15 -0
  202. package/src/locales/zh_tw.json +15 -0
  203. package/src/locales.ts +6 -0
  204. package/src/types/canvas.ts +54 -0
  205. package/src/types/glb.ts +26 -0
  206. package/src/types/mdx-directives.ts +106 -0
  207. package/src/types/nav.ts +96 -0
  208. package/src/types/preview.ts +15 -0
  209. package/src/types/readability.ts +29 -0
  210. package/src/types/search.ts +57 -0
  211. package/src/types/tree.ts +19 -0
  212. package/src/types/viewer.ts +54 -0
  213. package/styles/content/base.css +198 -0
  214. package/styles/content/default-colors.css +51 -0
  215. package/styles/content/shiki.css +107 -0
  216. package/styles/content/typography.mjs +3637 -0
  217. package/styles/content.css +5 -0
  218. package/styles/image-zoom.css +83 -0
  219. package/styles/layout.css +28 -0
  220. package/styles/left-sidebar.css +140 -0
  221. package/styles/readability.css +50 -0
  222. package/styles/right-sidebar.css +6 -0
  223. package/styles/workspace.css +26 -0
  224. package/styles.css +430 -0
  225. package/README.md +0 -3
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, type ReactNode } from 'react';
4
+ import { Play, Timer } from 'lucide-react';
5
+ import {
6
+ ViewerToolbar,
7
+ ViewerControls,
8
+ } from '@terrafirmagreg-team/ui/components/blocks/viewer/controls';
9
+ import { MediaImage } from '@terrafirmagreg-team/ui/components/blocks/media/image';
10
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
11
+ import { useAsyncEffect } from '@terrafirmagreg-team/ui/hooks/use-async-value';
12
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
13
+ import {
14
+ AUTOPLAY_SPEED_MAX,
15
+ AUTOPLAY_SPEED_MIN,
16
+ useAutoplay,
17
+ useAutoplaySpeed,
18
+ } from '@terrafirmagreg-team/ui/hooks/viewer/use-viewer-prefs';
19
+ import { trimTransparent } from '@terrafirmagreg-team/ui/lib/media/trim-transparent';
20
+ import { nearbyIndexes } from '@terrafirmagreg-team/ui/lib/media/nearby-indexes';
21
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
22
+
23
+ export function Slideshow({
24
+ images,
25
+ alt,
26
+ border,
27
+ scale,
28
+ offset,
29
+ rotate,
30
+ caption,
31
+ }: {
32
+ images: Array<{ id: string; url: string | null }>;
33
+ alt: string;
34
+ border: boolean;
35
+ scale?: number;
36
+ offset?: number;
37
+ rotate?: boolean;
38
+ caption?: ReactNode;
39
+ }) {
40
+ const t = useTranslations();
41
+ const [autoplay, setAutoplay] = useAutoplay();
42
+ const [autoplaySpeed, setAutoplaySpeed] = useAutoplaySpeed();
43
+ const [hovered, setHovered] = useState(false);
44
+ const surfaceRef = useRef<HTMLDivElement>(null);
45
+ const [selected, setSelected] = useCycleIndex(images.length, {
46
+ enabled: autoplay,
47
+ paused: hovered,
48
+ intervalMs: autoplaySpeed * 1000,
49
+ });
50
+ const [trimmed, setTrimmed] = useState<Record<string, string>>({});
51
+ const urls = images.map((entry) => entry.url).join('\0');
52
+ const nearby = nearbyIndexes(selected, images.length);
53
+ const multi = images.length > 1;
54
+
55
+ useAsyncEffect((isCancelled) => {
56
+ const list = urls.split('\0');
57
+ for (const index of nearbyIndexes(selected, list.length)) {
58
+ const src = list[index];
59
+ if (!src) continue;
60
+ void trimTransparent(src).then((next) => {
61
+ if (isCancelled()) return;
62
+ setTrimmed((current) =>
63
+ current[src] === next ? current : { ...current, [src]: next },
64
+ );
65
+ });
66
+ }
67
+ }, [selected, urls]);
68
+
69
+ const transform = [
70
+ typeof offset === 'number' ? `translateY(${offset}px)` : '',
71
+ rotate ? 'rotate(90deg)' : '',
72
+ `scale(${typeof scale === 'number' ? scale : 1})`,
73
+ ]
74
+ .filter(Boolean)
75
+ .join(' ');
76
+
77
+ return (
78
+ <figure
79
+ className="not-prose my-3 flex flex-col gap-2"
80
+ onPointerEnter={() => setHovered(true)}
81
+ onPointerLeave={() => setHovered(false)}
82
+ onFocus={() => setHovered(true)}
83
+ onBlur={() => setHovered(false)}
84
+ >
85
+ <div
86
+ ref={surfaceRef}
87
+ className={cn(
88
+ 'relative mx-auto size-96 max-w-full overflow-hidden rounded-xl',
89
+ )}
90
+ >
91
+ {images.length === 0 ? (
92
+ <MediaImage
93
+ src={null}
94
+ alt={alt}
95
+ className="absolute inset-0 h-full w-full"
96
+ />
97
+ ) : null}
98
+ {images.map((entry, index) => {
99
+ if (!nearby.includes(index)) return null;
100
+ const current = index === selected;
101
+ const src = entry.url ? (trimmed[entry.url] ?? entry.url) : null;
102
+ return (
103
+ <span
104
+ key={entry.id}
105
+ aria-hidden={!current || undefined}
106
+ className={cn(
107
+ 'absolute inset-0 transition-opacity duration-500 ease-in-out',
108
+ current ? 'opacity-100' : 'pointer-events-none opacity-0',
109
+ )}
110
+ >
111
+ <MediaImage
112
+ src={src}
113
+ alt={current ? alt : ''}
114
+ className={cn(
115
+ 'h-full w-full object-contain',
116
+ border && 'rounded-md',
117
+ )}
118
+ style={{
119
+ transform,
120
+ transformOrigin: 'center',
121
+ }}
122
+ pixelated
123
+ />
124
+ </span>
125
+ );
126
+ })}
127
+ <ViewerToolbar>
128
+ <ViewerControls
129
+ pager={
130
+ multi
131
+ ? {
132
+ index: selected,
133
+ count: images.length,
134
+ onSelect: setSelected,
135
+ }
136
+ : undefined
137
+ }
138
+ expand={{ target: surfaceRef }}
139
+ settings={{
140
+ label: t('Viewer settings', { note: 'ui.viewer.settings' }),
141
+ sliders: multi
142
+ ? [
143
+ {
144
+ icon: <Timer />,
145
+ label: t('Autoplay speed', { note: 'ui.viewer.autoplay_speed' }),
146
+ min: AUTOPLAY_SPEED_MIN,
147
+ max: AUTOPLAY_SPEED_MAX,
148
+ step: 0.5,
149
+ disabled: !autoplay,
150
+ format: (value) => `${value.toFixed(1)}s`,
151
+ value: autoplaySpeed,
152
+ onChange: setAutoplaySpeed,
153
+ },
154
+ ]
155
+ : [],
156
+ toggles: multi
157
+ ? [
158
+ {
159
+ icon: <Play />,
160
+ label: t('Autoplay', { note: 'ui.viewer.autoplay' }),
161
+ checked: autoplay,
162
+ onChange: setAutoplay,
163
+ },
164
+ ]
165
+ : [],
166
+ }}
167
+ />
168
+ </ViewerToolbar>
169
+ </div>
170
+ {caption ? (
171
+ <figcaption className="mx-auto max-w-prose text-center text-sm text-muted-foreground">
172
+ {caption}
173
+ </figcaption>
174
+ ) : null}
175
+ </figure>
176
+ );
177
+ }
178
+
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+ import { Fragment } from 'react';
3
+ import {
4
+ Breadcrumb as BreadcrumbRoot,
5
+ BreadcrumbItem,
6
+ BreadcrumbList,
7
+ BreadcrumbPage,
8
+ BreadcrumbSeparator,
9
+ } from '@terrafirmagreg-team/ui/components/ui/breadcrumb';
10
+ import { LinkPreview } from '@terrafirmagreg-team/ui/components/blocks/link/link-preview';
11
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
12
+ import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
13
+
14
+ export function Breadcrumb({
15
+ items,
16
+ previews,
17
+ className,
18
+ }: {
19
+ items: Array<{ name: string; url?: string }>;
20
+ previews?: Record<string, PagePreviewData>;
21
+ className?: string;
22
+ }) {
23
+ if (!items.length) return null;
24
+ return (
25
+ <BreadcrumbRoot className={cn('min-w-0', className)}>
26
+ <BreadcrumbList>
27
+ {items.map((item, index) => (
28
+ <Fragment key={`${item.name}:${index}`}>
29
+ {index > 0 && <BreadcrumbSeparator />}
30
+ <BreadcrumbItem>
31
+ {item.url ? (
32
+ <LinkPreview
33
+ href={item.url}
34
+ preview={item.url ? previews?.[item.url] : undefined}
35
+ className="truncate transition-opacity hover:opacity-80"
36
+ >
37
+ {item.name}
38
+ </LinkPreview>
39
+ ) : (
40
+ <BreadcrumbPage className="truncate font-medium">
41
+ {item.name}
42
+ </BreadcrumbPage>
43
+ )}
44
+ </BreadcrumbItem>
45
+ </Fragment>
46
+ ))}
47
+ </BreadcrumbList>
48
+ </BreadcrumbRoot>
49
+ );
50
+ }
51
+
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import {
5
+ ContextMenu,
6
+ ContextMenuContent,
7
+ ContextMenuItem,
8
+ ContextMenuTrigger,
9
+ } from '@terrafirmagreg-team/ui/components/ui/context-menu';
10
+
11
+ export function CopyMenu({
12
+ items,
13
+ extra,
14
+ children,
15
+ }: {
16
+ items: { label: ReactNode; value: string; icon?: ReactNode }[];
17
+ extra?: ReactNode;
18
+ children: ReactNode;
19
+ }) {
20
+ const copy = (value: string) => () => {
21
+ void navigator.clipboard.writeText(value);
22
+ };
23
+ return (
24
+ <ContextMenu>
25
+ <ContextMenuTrigger className="contents">{children}</ContextMenuTrigger>
26
+ <ContextMenuContent>
27
+ {items.map((item, index) => (
28
+ <ContextMenuItem key={index} onClick={copy(item.value)}>
29
+ {item.icon}
30
+ {item.label}
31
+ </ContextMenuItem>
32
+ ))}
33
+ {extra}
34
+ </ContextMenuContent>
35
+ </ContextMenu>
36
+ );
37
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+ import { usePathname } from 'next/navigation';
3
+ import type { ComponentProps, ReactNode } from 'react';
4
+ import { useUiComponents } from '@terrafirmagreg-team/ui/provider';
5
+ import { isActive, safeURL } from '@terrafirmagreg-team/ui/lib/utils/urls';
6
+ import { Hint } from '@terrafirmagreg-team/ui/components/blocks/tooltip/hint';
7
+ import type { LinkItemType, LinkProps } from '@terrafirmagreg-team/ui/types/nav';
8
+
9
+ export function Link({
10
+ ref,
11
+ href = '#',
12
+ external = !!(href.match(/^\w+:/) || href.startsWith('//')),
13
+ prefetch,
14
+ children,
15
+ ...props
16
+ }: LinkProps) {
17
+ const { Link: UiLink } = useUiComponents();
18
+ if (external)
19
+ return (
20
+ <a ref={ref} href={href} rel="noreferrer noopener" target="_blank" {...props}>
21
+ {children}
22
+ </a>
23
+ );
24
+ return (
25
+ <UiLink ref={ref} href={href} prefetch={prefetch} {...props}>
26
+ {children}
27
+ </UiLink>
28
+ );
29
+ }
30
+
31
+ export default Link;
32
+
33
+ export function SafeLink({
34
+ href,
35
+ children,
36
+ className,
37
+ title,
38
+ }: {
39
+ href?: string;
40
+ children: ReactNode;
41
+ className?: string;
42
+ title?: string;
43
+ }) {
44
+ const safe = safeURL(href);
45
+ const node = safe ? (
46
+ <Link href={safe} className={className}>
47
+ {children}
48
+ </Link>
49
+ ) : (
50
+ <span className={className}>{children}</span>
51
+ );
52
+ return <Hint label={title}>{node}</Hint>;
53
+ }
54
+
55
+ export function isLinkItemActive(
56
+ link: LinkItemType,
57
+ pathname: string,
58
+ ): boolean {
59
+ if (link.type === 'custom' || !link.url) return false;
60
+ if (link.active === 'none') return false;
61
+ return isActive(link.url, pathname, link.active === 'nested-url');
62
+ }
63
+
64
+ export function LinkItem({
65
+ ref,
66
+ item,
67
+ ...props
68
+ }: Omit<ComponentProps<'a'>, 'href'> & {
69
+ item: Extract<LinkItemType, { url: string }>;
70
+ }) {
71
+ const pathname = usePathname();
72
+ const active = isLinkItemActive(item, pathname);
73
+
74
+ return (
75
+ <Link
76
+ ref={ref}
77
+ href={item.url}
78
+ external={item.external}
79
+ {...props}
80
+ data-active={active}
81
+ >
82
+ {props.children}
83
+ </Link>
84
+ );
85
+ }
86
+
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { Menu } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
5
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
6
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from '@terrafirmagreg-team/ui/components/ui/dropdown-menu';
14
+
15
+ export type SiteMenuItem = {
16
+ label: string;
17
+ icon?: ReactNode;
18
+ } & ({ url: string; external?: boolean } | { onClick: () => void });
19
+
20
+ export function SiteMenu({
21
+ label,
22
+ items,
23
+ }: {
24
+ label?: string;
25
+ items: readonly (SiteMenuItem | 'separator' | null | undefined | false)[];
26
+ }) {
27
+ return (
28
+ <DropdownMenu modal={false}>
29
+ <DropdownMenuTrigger
30
+ aria-label={label}
31
+ className={cn(
32
+ buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
33
+ 'size-8 text-muted-foreground',
34
+ )}
35
+ >
36
+ <Menu />
37
+ </DropdownMenuTrigger>
38
+ <DropdownMenuContent side="bottom" align="end" className="min-w-44">
39
+ {items.map((item, i) => {
40
+ if (item === 'separator') return <DropdownMenuSeparator key={i} />;
41
+ if (!item) return null;
42
+ const inner = (
43
+ <>
44
+ {item.icon != null && (
45
+ <span aria-hidden="true" className="contents [&_svg]:size-4">
46
+ {item.icon}
47
+ </span>
48
+ )}
49
+ {item.label}
50
+ </>
51
+ );
52
+ return 'onClick' in item ? (
53
+ <DropdownMenuItem key={i} closeOnClick className="gap-2" onClick={item.onClick}>
54
+ {inner}
55
+ </DropdownMenuItem>
56
+ ) : (
57
+ <DropdownMenuItem
58
+ key={i}
59
+ closeOnClick
60
+ className="gap-2"
61
+ render={
62
+ <a
63
+ href={item.url}
64
+ {...(item.external
65
+ ? { target: '_blank', rel: 'noopener noreferrer' }
66
+ : {})}
67
+ />
68
+ }
69
+ >
70
+ {inner}
71
+ </DropdownMenuItem>
72
+ );
73
+ })}
74
+ </DropdownMenuContent>
75
+ </DropdownMenu>
76
+ );
77
+ }
78
+
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+ import { useUiComponents } from '@terrafirmagreg-team/ui/provider';
4
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
5
+
6
+ export type SiteTitleProps = {
7
+ icon: string;
8
+ name: ReactNode;
9
+ subtitle?: ReactNode;
10
+ href?: string;
11
+ } & Omit<ComponentProps<'a'>, 'href'>;
12
+
13
+ export function SiteTitle({
14
+ icon,
15
+ name,
16
+ subtitle,
17
+ href,
18
+ className,
19
+ ...props
20
+ }: SiteTitleProps) {
21
+ const { Link } = useUiComponents();
22
+ const cls = cn(
23
+ 'inline-flex min-w-0 items-center gap-2.5 text-nav-title font-medium',
24
+ className,
25
+ );
26
+ const sub = subtitle ? (
27
+ <span
28
+ className="truncate text-2xs font-normal text-muted-foreground"
29
+ suppressHydrationWarning
30
+ >
31
+ {subtitle}
32
+ </span>
33
+ ) : null;
34
+ if (!href) {
35
+ return (
36
+ <span className={cls}>
37
+ {/* eslint-disable-next-line @next/next/no-img-element -- site icon */}
38
+ <img src={icon} alt="" className="size-5 shrink-0" />
39
+ <span className="flex min-w-0 flex-col leading-tight">
40
+ <span className="truncate">{name}</span>
41
+ {sub}
42
+ </span>
43
+ </span>
44
+ );
45
+ }
46
+ return (
47
+ <span className={cls}>
48
+ <Link href={href} className="shrink-0">
49
+ {/* eslint-disable-next-line @next/next/no-img-element -- site icon */}
50
+ <img src={icon} alt="" className="size-5 shrink-0" />
51
+ </Link>
52
+ <span className="flex min-w-0 flex-col leading-tight">
53
+ <Link href={href} className="truncate" {...props}>
54
+ {name}
55
+ </Link>
56
+ {sub}
57
+ </span>
58
+ </span>
59
+ );
60
+ }
61
+
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuGroup,
6
+ DropdownMenuItem,
7
+ DropdownMenuLabel,
8
+ DropdownMenuTrigger,
9
+ } from '@terrafirmagreg-team/ui/components/ui/dropdown-menu';
10
+ import { CheckIcon, ChevronsUpDown } from 'lucide-react';
11
+ import {
12
+ type HTMLAttributes,
13
+ type ReactNode,
14
+ useMemo,
15
+ useState,
16
+ } from 'react';
17
+ import { useUiComponents } from '@terrafirmagreg-team/ui/provider';
18
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
19
+ import { useCloseOnScroll } from '@terrafirmagreg-team/ui/hooks/use-close-on-scroll';
20
+ import { useMarquee } from '@terrafirmagreg-team/ui/hooks/use-marquee';
21
+ import type { SwitcherOption, SwitcherProps } from '@terrafirmagreg-team/ui/types/nav';
22
+
23
+ function SwitcherIcon({
24
+ icon,
25
+ title,
26
+ className,
27
+ }: {
28
+ icon?: ReactNode;
29
+ title?: ReactNode;
30
+ className?: string;
31
+ }) {
32
+ const letter = typeof title === 'string' ? title.charAt(0) : null;
33
+ return (
34
+ <span
35
+ className={cn(
36
+ 'flex items-center justify-center shrink-0 size-4 font-medium',
37
+ !icon && letter != null &&
38
+ 'rounded bg-muted text-2xs text-muted-foreground',
39
+ className,
40
+ )}
41
+ >
42
+ {icon ?? letter}
43
+ </span>
44
+ );
45
+ }
46
+
47
+ function SwitcherItemContent({
48
+ item,
49
+ selected,
50
+ }: {
51
+ item: SwitcherOption;
52
+ selected?: string;
53
+ }) {
54
+ return (
55
+ <>
56
+ <SwitcherIcon icon={item.icon} title={item.title} />
57
+ <span className="flex flex-col flex-1 min-w-0">
58
+ <Marquee className="font-medium">{item.title}</Marquee>
59
+ {item.description != null && (
60
+ <Marquee className="text-xs text-muted-foreground">
61
+ {item.description}
62
+ </Marquee>
63
+ )}
64
+ </span>
65
+ {item.value === selected && (
66
+ <CheckIcon className="text-primary" />
67
+ )}
68
+ </>
69
+ );
70
+ }
71
+
72
+ const itemClassName = 'min-h-9 gap-3 px-2.5 py-2';
73
+
74
+ export function Switcher({
75
+ options,
76
+ selected,
77
+ onSelect,
78
+ placeholder,
79
+ icon,
80
+ header,
81
+ side = 'bottom',
82
+ disabled,
83
+ className,
84
+ ...props
85
+ }: SwitcherProps) {
86
+ const [open, setOpen] = useState(false);
87
+ const { Link } = useUiComponents();
88
+ useCloseOnScroll(open, () => setOpen(false));
89
+
90
+ const current = useMemo(
91
+ () => options.find((option) => option.value === selected),
92
+ [options, selected],
93
+ );
94
+
95
+ const items = useMemo(
96
+ () =>
97
+ options.map((item) =>
98
+ item.url ? (
99
+ <DropdownMenuItem
100
+ key={item.value}
101
+ closeOnClick
102
+ disabled={item.disabled}
103
+ render={<Link href={item.url} />}
104
+ className={itemClassName}
105
+ >
106
+ <SwitcherItemContent item={item} selected={selected} />
107
+ </DropdownMenuItem>
108
+ ) : (
109
+ <DropdownMenuItem
110
+ key={item.value}
111
+ closeOnClick
112
+ disabled={item.disabled}
113
+ onClick={() => onSelect?.(item.value)}
114
+ className={itemClassName}
115
+ >
116
+ <SwitcherItemContent item={item} selected={selected} />
117
+ </DropdownMenuItem>
118
+ ),
119
+ ),
120
+ [options, selected, onSelect, Link],
121
+ );
122
+
123
+ const triggerIcon = icon ?? current?.icon;
124
+ const triggerClassName = cn(
125
+ 'flex w-full h-10 items-center gap-3 rounded-xl border bg-secondary/50 ps-[calc(--spacing(2.5)_-_1px)] pe-2.5 text-start font-medium outline-none transition-colors [&_svg]:size-4 [&_svg]:shrink-0',
126
+ className,
127
+ );
128
+
129
+ if (disabled) {
130
+ return (
131
+ <button
132
+ type="button"
133
+ disabled
134
+ aria-disabled="true"
135
+ className={cn(triggerClassName, 'cursor-not-allowed opacity-50')}
136
+ {...props}
137
+ >
138
+ <SwitcherIcon icon={triggerIcon} title={current?.title ?? placeholder} />
139
+ <span className="flex-1 truncate text-muted-foreground">
140
+ {current?.title ?? placeholder}
141
+ </span>
142
+ <ChevronsUpDown className="!size-3.5 text-muted-foreground" />
143
+ </button>
144
+ );
145
+ }
146
+
147
+ if (options.length === 0) return null;
148
+
149
+ return (
150
+ <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
151
+ <DropdownMenuTrigger
152
+ className={cn(
153
+ triggerClassName,
154
+ 'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent',
155
+ )}
156
+ {...props}
157
+ >
158
+ <SwitcherIcon icon={triggerIcon} title={current?.title} />
159
+ <Marquee
160
+ className={cn('flex-1', !current && 'text-muted-foreground')}
161
+ >
162
+ {current ? current.title : placeholder}
163
+ </Marquee>
164
+ <ChevronsUpDown className="!size-3.5 text-muted-foreground" />
165
+ </DropdownMenuTrigger>
166
+ <DropdownMenuContent
167
+ side={side}
168
+ align="start"
169
+ sideOffset={8}
170
+ alignOffset={-4}
171
+ className="w-[calc(var(--anchor-width)+--spacing(2))] rounded-2xl"
172
+ >
173
+ {header != null ? (
174
+ <DropdownMenuGroup>
175
+ <DropdownMenuLabel>{header}</DropdownMenuLabel>
176
+ {items}
177
+ </DropdownMenuGroup>
178
+ ) : (
179
+ items
180
+ )}
181
+ </DropdownMenuContent>
182
+ </DropdownMenu>
183
+ );
184
+ }
185
+
186
+ export type { SwitcherOption, SwitcherProps } from '@terrafirmagreg-team/ui/types/nav';
187
+
188
+ function Marquee({
189
+ className,
190
+ children,
191
+ ...props
192
+ }: HTMLAttributes<HTMLSpanElement>) {
193
+ const ref = useMarquee<HTMLSpanElement>();
194
+ return (
195
+ <span
196
+ ref={ref}
197
+ className={cn('block min-w-0 overflow-hidden', className)}
198
+ {...props}
199
+ >
200
+ <span className="marquee-inner block whitespace-nowrap">
201
+ {children}
202
+ </span>
203
+ </span>
204
+ );
205
+ }
206
+