@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,244 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import {
5
+ useReactFlow,
6
+ useStoreApi,
7
+ } from '@terrafirmagreg-team/ui/lib/canvas/xyflow';
8
+ import { readStoredViewport } from '@terrafirmagreg-team/ui/lib/canvas/viewport';
9
+ import { zoomToFitBox } from '@terrafirmagreg-team/ui/lib/canvas/geometry';
10
+ import type { FlowContentBox } from '@terrafirmagreg-team/ui/types/canvas';
11
+
12
+ export function FlowViewport({
13
+ storageKey,
14
+ resetKey,
15
+ selectedId,
16
+ suppressPanRef,
17
+ nodes,
18
+ contentBox,
19
+ viewSize,
20
+ focusZoom,
21
+ minZoom,
22
+ maxZoom,
23
+ restoreViewport = true,
24
+ ready = true,
25
+ onReady,
26
+ initFocusId,
27
+ userMovedRef,
28
+ }: {
29
+ storageKey: string;
30
+ resetKey: string;
31
+ selectedId?: string | null;
32
+
33
+ initFocusId?: string | null;
34
+ suppressPanRef?: { current: string | null };
35
+
36
+ userMovedRef?: { current: boolean };
37
+ nodes: ReadonlyArray<{ id: string; position: { x: number; y: number } }>;
38
+ contentBox: FlowContentBox | null;
39
+ viewSize: { w: number; h: number };
40
+ focusZoom: number;
41
+ minZoom?: number;
42
+ maxZoom?: number;
43
+ restoreViewport?: boolean;
44
+ ready?: boolean;
45
+ onReady: () => void;
46
+ }) {
47
+ const { setCenter, setViewport, getZoom } = useReactFlow();
48
+ const storeApi = useStoreApi();
49
+ const initRef = useRef<string | null>(null);
50
+ const preInitRef = useRef<string | null>(null);
51
+ const nodesRef = useRef(nodes);
52
+ const boxRef = useRef(contentBox);
53
+
54
+ useEffect(() => {
55
+ nodesRef.current = nodes;
56
+ boxRef.current = contentBox;
57
+ }, [nodes, contentBox]);
58
+
59
+ const initKey = `${resetKey}:${ready ? 'ready' : 'warm'}`;
60
+
61
+ const lastSelectedRef = useRef<string | null>(null);
62
+ useEffect(() => {
63
+ lastSelectedRef.current = null;
64
+ if (userMovedRef) userMovedRef.current = false;
65
+ }, [resetKey, userMovedRef]);
66
+
67
+ useEffect(() => {
68
+ if (viewSize.w < 1 || viewSize.h < 1) return;
69
+ if (!ready) {
70
+
71
+ initRef.current = null;
72
+ } else if (initRef.current === initKey) return;
73
+ if (userMovedRef?.current) return;
74
+ const box = boxRef.current ?? contentBox;
75
+ const initNode = initFocusId
76
+ ? nodesRef.current.find((entry) => entry.id === initFocusId)
77
+ : undefined;
78
+ if (!ready) {
79
+
80
+ if (preInitRef.current === resetKey) return;
81
+ if (initNode) {
82
+ void setCenter(initNode.position.x, initNode.position.y, {
83
+ zoom: focusZoom,
84
+ duration: 0,
85
+ });
86
+ } else if (box) {
87
+ void setCenter(box.midX, box.midY, {
88
+ zoom: Math.min(
89
+ zoomToFitBox(box, viewSize.w, viewSize.h, minZoom, maxZoom),
90
+ 1,
91
+ ),
92
+ duration: 0,
93
+ });
94
+ } else {
95
+ return;
96
+ }
97
+ preInitRef.current = resetKey;
98
+ return;
99
+ }
100
+ const cached = restoreViewport ? readStoredViewport(storageKey) : null;
101
+ if (cached) {
102
+
103
+ const px = -cached.x / cached.zoom;
104
+ const py = -cached.y / cached.zoom;
105
+ const pw = viewSize.w / cached.zoom;
106
+ const ph = viewSize.h / cached.zoom;
107
+ let hasContent = false;
108
+ for (const node of storeApi.getState().nodeLookup.values()) {
109
+ if (node.hidden) continue;
110
+ const abs = node.internals.positionAbsolute;
111
+ const w = node.measured?.width ?? node.width ?? 0;
112
+ const h = node.measured?.height ?? node.height ?? 0;
113
+ if (
114
+ abs.x < px + pw &&
115
+ abs.x + w > px &&
116
+ abs.y < py + ph &&
117
+ abs.y + h > py
118
+ ) {
119
+ hasContent = true;
120
+ break;
121
+ }
122
+ }
123
+ if (hasContent) {
124
+ void setViewport(cached);
125
+ initRef.current = initKey;
126
+ onReady();
127
+ return;
128
+ }
129
+ }
130
+ const focusId =
131
+ selectedId && selectedId !== suppressPanRef?.current
132
+ ? selectedId
133
+ : undefined;
134
+ const tooWide =
135
+ !!box &&
136
+ Math.min(
137
+ (viewSize.w * 0.76) / box.width,
138
+ (viewSize.h * 0.76) / box.height,
139
+ ) <
140
+ focusZoom * 0.5;
141
+ const wanted = focusId ?? (tooWide ? initFocusId : undefined);
142
+ if (wanted) {
143
+ const node = nodesRef.current.find((entry) => entry.id === wanted);
144
+ if (node) {
145
+ void setCenter(node.position.x, node.position.y, {
146
+ zoom: focusZoom,
147
+ duration: 0,
148
+ });
149
+ initRef.current = initKey;
150
+ onReady();
151
+ return;
152
+ }
153
+ }
154
+ if (!box) return;
155
+ void setCenter(box.midX, box.midY, {
156
+ zoom: Math.min(
157
+ zoomToFitBox(box, viewSize.w, viewSize.h, minZoom, maxZoom),
158
+ focusZoom,
159
+ ),
160
+ duration: 0,
161
+ });
162
+ initRef.current = initKey;
163
+ onReady();
164
+ }, [
165
+ contentBox,
166
+ focusZoom,
167
+ initKey,
168
+ minZoom,
169
+ maxZoom,
170
+ initFocusId,
171
+ nodes,
172
+ onReady,
173
+ ready,
174
+ resetKey,
175
+ restoreViewport,
176
+ selectedId,
177
+ suppressPanRef,
178
+ userMovedRef,
179
+ setCenter,
180
+ setViewport,
181
+ storageKey,
182
+ storeApi,
183
+ viewSize,
184
+ ]);
185
+
186
+ const lastBoxRef = useRef<FlowContentBox | null>(null);
187
+ useEffect(() => {
188
+ if (contentBox === lastBoxRef.current) return;
189
+ lastBoxRef.current = contentBox;
190
+ if (!ready || !contentBox || viewSize.w < 1 || viewSize.h < 1) return;
191
+ if (initRef.current !== initKey) return;
192
+ const { transform } = storeApi.getState();
193
+ const px = -transform[0] / transform[2];
194
+ const py = -transform[1] / transform[2];
195
+ const pw = viewSize.w / transform[2];
196
+ const ph = viewSize.h / transform[2];
197
+ for (const node of storeApi.getState().nodeLookup.values()) {
198
+ if (node.hidden) continue;
199
+ const abs = node.internals.positionAbsolute;
200
+ const w = node.measured?.width ?? node.width ?? 0;
201
+ const h = node.measured?.height ?? node.height ?? 0;
202
+ if (
203
+ abs.x < px + pw &&
204
+ abs.x + w > px &&
205
+ abs.y < py + ph &&
206
+ abs.y + h > py
207
+ )
208
+ return;
209
+ }
210
+ void setCenter(contentBox.midX, contentBox.midY, {
211
+ zoom: Math.min(
212
+ zoomToFitBox(contentBox, viewSize.w, viewSize.h, minZoom, maxZoom),
213
+ focusZoom,
214
+ ),
215
+ duration: 0,
216
+ });
217
+ }, [
218
+ contentBox,
219
+ focusZoom,
220
+ initKey,
221
+ minZoom,
222
+ maxZoom,
223
+ ready,
224
+ setCenter,
225
+ storeApi,
226
+ viewSize,
227
+ ]);
228
+
229
+ useEffect(() => {
230
+ if (selectedId === lastSelectedRef.current) return;
231
+ lastSelectedRef.current = selectedId ?? null;
232
+ if (initRef.current !== resetKey || !selectedId) return;
233
+ if (selectedId === suppressPanRef?.current) return;
234
+ const node = nodesRef.current.find((entry) => entry.id === selectedId);
235
+ if (!node) return;
236
+ void setCenter(node.position.x, node.position.y, {
237
+ zoom: getZoom() || focusZoom,
238
+ duration: 300,
239
+ });
240
+ }, [focusZoom, getZoom, resetKey, selectedId, suppressPanRef, setCenter]);
241
+
242
+ return null;
243
+ }
244
+
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { useRef, type ReactNode } from 'react';
4
+ import { useElementSize } from '@terrafirmagreg-team/ui/hooks/use-element-size';
5
+
6
+ export function FitBox({
7
+ w,
8
+ children,
9
+ }: {
10
+ w: number;
11
+ children: ReactNode;
12
+ }) {
13
+ const ref = useRef<HTMLDivElement>(null);
14
+ const { w: avail } = useElementSize(ref);
15
+ const fit = w > 0 && avail > 0 ? Math.min(1, avail / w) : 1;
16
+ return (
17
+ <div ref={ref} className="w-full">
18
+ <div
19
+ className="mx-auto w-fit"
20
+ style={fit < 1 ? { zoom: fit } : undefined}
21
+ >
22
+ {children}
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,78 @@
1
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
2
+ import { GitFork, Star } from 'lucide-react';
3
+ import { SiGithub } from '@icons-pack/react-simple-icons';
4
+ import { ItemRow } from '@terrafirmagreg-team/ui/components/blocks/list/row';
5
+
6
+ interface RepositoryInfo {
7
+ stars: number;
8
+ forks: number;
9
+ }
10
+
11
+ async function fetchRepositoryInfo(
12
+ owner: string,
13
+ repo: string,
14
+ ): Promise<RepositoryInfo | null> {
15
+ try {
16
+ const response = await fetch(
17
+ `https://api.github.com/repos/${owner}/${repo}`,
18
+ {
19
+ next: { revalidate: 3600 },
20
+ headers: process.env.GITHUB_TOKEN
21
+ ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
22
+ : undefined,
23
+ } as RequestInit,
24
+ );
25
+ if (!response.ok) return null;
26
+ const data = await response.json();
27
+ return {
28
+ stars: data.stargazers_count,
29
+ forks: data.forks_count,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ const formatterOptions: Intl.NumberFormatOptions = {
37
+ notation: 'compact',
38
+ maximumFractionDigits: 1,
39
+ };
40
+
41
+ export async function GithubInfo({
42
+ owner,
43
+ repo,
44
+ locale,
45
+ className,
46
+ }: {
47
+ owner: string;
48
+ repo: string;
49
+ locale?: Intl.LocalesArgument;
50
+ className?: string;
51
+ }) {
52
+ const info = await fetchRepositoryInfo(owner, repo);
53
+ const formatter = new Intl.NumberFormat(locale, formatterOptions);
54
+
55
+ return (
56
+ <ItemRow
57
+ href={`https://github.com/${owner}/${repo}`}
58
+ target="_blank"
59
+ rel="noreferrer noopener"
60
+ variant="outline"
61
+ size="default"
62
+ media={<SiGithub color="currentColor" className="size-3.5" />}
63
+ title={`${owner}/${repo}`}
64
+ description={
65
+ info ? (
66
+ <span className="flex items-center gap-1">
67
+ <Star className="size-3" />
68
+ {formatter.format(info.stars)}
69
+ <GitFork className="ms-2 size-3" />
70
+ {formatter.format(info.forks)}
71
+ </span>
72
+ ) : undefined
73
+ }
74
+ className={cn('bg-card', className)}
75
+ />
76
+ );
77
+ }
78
+
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { useEffect, useState, type ReactNode } from 'react';
3
+ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
4
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
5
+ import type { LangContent } from '@terrafirmagreg-team/translate/types/content';
6
+ import { UntranslatedContext } from '@terrafirmagreg-team/ui/hooks/use-lang';
7
+
8
+ export { UntranslatedContext };
9
+
10
+ export type LocaleNodeResult = { node: ReactNode; missing: boolean } | null;
11
+ export type LoadLocaleNode = (lang: Lang) => Promise<LocaleNodeResult>;
12
+
13
+ export function LangContentSwitch({
14
+ content,
15
+ loadLang,
16
+ }: {
17
+ content: LangContent;
18
+ loadLang?: LoadLocaleNode;
19
+ }) {
20
+ const lang = useResolvedLang();
21
+ const [loaded, setLoaded] = useState<
22
+ Partial<Record<Lang, LocaleNodeResult>>
23
+ >({});
24
+ const result =
25
+ lang === content.lang
26
+ ? { node: content.node, missing: false }
27
+ : loaded[lang];
28
+
29
+ useEffect(() => {
30
+ if (!loadLang || lang === content.lang || lang in loaded) return;
31
+ let live = true;
32
+ void loadLang(lang).then((next) => {
33
+ if (live) {
34
+ setLoaded((map) => (lang in map ? map : { ...map, [lang]: next }));
35
+ }
36
+ });
37
+ return () => {
38
+ live = false;
39
+ };
40
+ }, [lang, content.lang, loadLang, loaded]);
41
+
42
+ const missing =
43
+ result === undefined
44
+ ? !loadLang && lang !== content.lang
45
+ : (result?.missing ?? lang !== content.lang);
46
+ return (
47
+ <UntranslatedContext.Provider value={missing}>
48
+ {(result ?? content).node}
49
+ </UntranslatedContext.Provider>
50
+ );
51
+ }
52
+
@@ -0,0 +1,25 @@
1
+ import { Globe } from 'lucide-react';
2
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
3
+ import countries from 'flag-icons/country.json';
4
+
5
+ const CODES = new Set(countries.map((country) => country.code));
6
+
7
+ export function LangFlag({
8
+ locale,
9
+ className,
10
+ }: {
11
+ locale: string;
12
+ className?: string;
13
+ }) {
14
+ const region = locale.split(/[-_]/)[1]?.toLowerCase();
15
+ if (!region || !CODES.has(region)) {
16
+ return <Globe className={cn('size-4', className)} />;
17
+ }
18
+ return (
19
+ <span
20
+ aria-hidden
21
+ className={cn('fi fis rounded-full text-base', `fi-${region}`, className)}
22
+ />
23
+ );
24
+ }
25
+
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import NextLink from 'next/link';
3
+ import type { ComponentProps } from 'react';
4
+ import { hrefWithLang } from '@terrafirmagreg-team/translate/lib/url';
5
+ import {
6
+ useDefaultLang,
7
+ useResolvedLang,
8
+ } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
9
+
10
+ export function LangLink({
11
+ href,
12
+ ...props
13
+ }: ComponentProps<'a'> & { prefetch?: boolean }) {
14
+ const lang = useResolvedLang();
15
+ const defaultLang = useDefaultLang();
16
+ const nextHref =
17
+ typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')
18
+ ? hrefWithLang(href, lang, defaultLang)
19
+ : href;
20
+
21
+ return <NextLink href={nextHref ?? href ?? '#'} {...props} />;
22
+ }
23
+
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { Suspense, useEffect } from 'react';
3
+ import { useRouter, useSearchParams } from 'next/navigation';
4
+ import { useLocaleRegistry } from '@terrafirmagreg-team/translate/hooks/use-locale-registry';
5
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
6
+ import { resolveLang } from '@terrafirmagreg-team/translate/lib/code';
7
+ import { langFromFragment, withLang } from '@terrafirmagreg-team/translate/lib/url';
8
+
9
+ function RedirectLink({ href }: { href: string }) {
10
+ const t = useTranslations();
11
+ return (
12
+ <div className="flex flex-1 flex-col items-center justify-center py-16">
13
+ <a
14
+ href={href}
15
+ className="text-sm text-muted-foreground underline underline-offset-4 transition-colors hover:text-accent-foreground"
16
+ >
17
+ {t('Redirecting…', { note: 'ui.redirecting' })}
18
+ </a>
19
+ </div>
20
+ );
21
+ }
22
+
23
+ function Redirect({ to }: { to: string }) {
24
+ const router = useRouter();
25
+ const searchParams = useSearchParams();
26
+ const { languages, defaultLanguage } = useLocaleRegistry();
27
+ const lang = resolveLang(
28
+ searchParams.get('lang') ??
29
+ (typeof window === 'undefined'
30
+ ? undefined
31
+ : langFromFragment(window.location.hash)),
32
+ languages,
33
+ defaultLanguage,
34
+ );
35
+ useEffect(() => {
36
+ router.replace(withLang(to, lang, defaultLanguage));
37
+ }, [to, router, lang, defaultLanguage]);
38
+ return <RedirectLink href={withLang(to, lang, defaultLanguage)} />;
39
+ }
40
+
41
+ export function LangRedirect({ to }: { to: string }) {
42
+ return (
43
+ <Suspense fallback={<RedirectLink href={to} />}>
44
+ <Redirect to={to} />
45
+ </Suspense>
46
+ );
47
+ }
48
+
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { useMemo } from 'react';
3
+ import { Languages } from 'lucide-react';
4
+ import { LangFlag } from '@terrafirmagreg-team/ui/components/blocks/lang/flag';
5
+ import { Switcher } from '@terrafirmagreg-team/ui/components/blocks/nav/switcher';
6
+ import type { SwitcherOption, SwitcherProps } from '@terrafirmagreg-team/ui/types/nav';
7
+
8
+ export type LangSwitcherLocale = string | { locale: string; name?: string };
9
+
10
+ const languageName = (lang: string): string => {
11
+ const bcp47 = lang.replace(/_/g, '-');
12
+ try {
13
+ const name =
14
+ new Intl.DisplayNames([bcp47], { type: 'language' }).of(bcp47) ?? lang;
15
+ return name.charAt(0).toUpperCase() + name.slice(1);
16
+ } catch {
17
+ return lang;
18
+ }
19
+ };
20
+
21
+ export function LangSwitcher({
22
+ locales,
23
+ label,
24
+ icon,
25
+ ...props
26
+ }: Omit<SwitcherProps, 'options' | 'header'> & {
27
+ locales: LangSwitcherLocale[];
28
+ label?: string;
29
+ }) {
30
+ const options = useMemo<SwitcherOption[]>(
31
+ () =>
32
+ locales.map((item) => {
33
+ const value = typeof item === 'string' ? item : item.locale;
34
+ return {
35
+ value,
36
+ title:
37
+ (typeof item === 'string' ? undefined : item.name) ??
38
+ languageName(value),
39
+ icon: <LangFlag locale={value} />,
40
+ };
41
+ }),
42
+ [locales],
43
+ );
44
+ return (
45
+ <Switcher
46
+ options={options}
47
+ icon={icon ?? <Languages />}
48
+ placeholder={label}
49
+ header={label}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import { Callout } from '@terrafirmagreg-team/ui/components/mdx/callout';
3
+ import { useUntranslated } from '@terrafirmagreg-team/ui/hooks/use-lang';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import type { TranslationState } from '@terrafirmagreg-team/translate/server';
6
+
7
+ export function UntranslatedCallout({
8
+ copied,
9
+ }: {
10
+
11
+ copied?: TranslationState;
12
+ }) {
13
+ const missing = useUntranslated();
14
+ const t = useTranslations();
15
+ if (!missing && !copied) return null;
16
+ const full = missing || copied === 'untranslated';
17
+ return (
18
+ <Callout
19
+ type="warn"
20
+ title={
21
+ full ? t('This page is not translated', { note: 'untranslated.title' }) : t('This page is not fully translated', { note: 'untranslated.partial_title' })
22
+ }
23
+ className="shrink-0"
24
+ >
25
+ <p>{full ? t('Showing the English version. A translation is not available yet.', { note: 'untranslated.body' }) : t('Some content is still shown in English.', { note: 'untranslated.partial_body' })}</p>
26
+ </Callout>
27
+ );
28
+ }
29
+
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+ import { Hint } from '@terrafirmagreg-team/ui/components/blocks/tooltip/hint';
3
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+
6
+ function LanguagesIcon({ className }: { className?: string }) {
7
+ return (
8
+ <svg
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ className={className}
16
+ aria-hidden
17
+ >
18
+ <path d="m5 8 6 6" />
19
+ <path d="m4 14 6-6 2-3" />
20
+ <path d="M2 5h12" />
21
+ <path d="M7 2h1" />
22
+ <path d="m22 22-5-10-5 10" />
23
+ <path d="M14 18h6" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ export function UntranslatedChip({ className }: { className?: string }) {
29
+ const t = useTranslations();
30
+ return (
31
+ <Hint
32
+ side="top"
33
+ label={
34
+ <span className="block max-w-56">
35
+ <span className="mb-0.5 block font-semibold">
36
+ {t('This page is not translated', { note: 'untranslated.title' })}
37
+ </span>
38
+ {t('Showing the English version. A translation is not available yet.', { note: 'untranslated.body' })}
39
+ </span>
40
+ }
41
+ >
42
+ <span
43
+ className={cn(
44
+ 'inline-flex cursor-help items-center gap-1 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400',
45
+ className,
46
+ )}
47
+ >
48
+ <LanguagesIcon className="size-3" />
49
+ {t('Untranslated', { note: 'untranslated.chip' })}
50
+ </span>
51
+ </Hint>
52
+ );
53
+ }