@terrafirmagreg-team/kit 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.
- package/package.json +56 -3
- package/src/components/blocks/nav-title.tsx +62 -0
- package/src/components/outline/dropdown.tsx +68 -0
- package/src/components/outline/index.ts +11 -0
- package/src/components/outline/list.tsx +121 -0
- package/src/components/outline/outline.tsx +50 -0
- package/src/components/outline/popover.tsx +77 -0
- package/src/components/page-actions.tsx +202 -0
- package/src/components/relative-link.tsx +19 -0
- package/src/components/toc/base.tsx +66 -0
- package/src/components/toc/block.tsx +112 -0
- package/src/components/toc/clerk.tsx +266 -0
- package/src/components/toc/default.tsx +334 -0
- package/src/components/version-menu.tsx +130 -0
- package/src/components/views/dataset.tsx +72 -0
- package/src/components/views/section.tsx +152 -0
- package/src/components/workspace/content.tsx +54 -0
- package/src/components/workspace/index.ts +13 -0
- package/src/components/workspace/pagination.tsx +74 -0
- package/src/components/workspace/workspace.tsx +149 -0
- package/src/hooks/use-catalog.ts +12 -0
- package/src/hooks/use-current-dataset.ts +24 -0
- package/src/hooks/use-dataset-accent.ts +14 -0
- package/src/hooks/use-nav-options.ts +87 -0
- package/src/hooks/use-section-names.ts +40 -0
- package/src/hooks/use-toc.ts +10 -0
- package/src/hooks/use-version-pref.ts +31 -0
- package/src/hooks/use-versioned-dataset.ts +21 -0
- package/src/lib/breadcrumb.ts +65 -0
- package/src/lib/config.ts +111 -0
- package/src/lib/dataset-links.ts +31 -0
- package/src/lib/datasets.ts +241 -0
- package/src/lib/landing.ts +48 -0
- package/src/lib/layout.ts +16 -0
- package/src/lib/locale/renderer.ts +49 -0
- package/src/lib/order.ts +89 -0
- package/src/lib/page.ts +109 -0
- package/src/lib/pin-tree.ts +38 -0
- package/src/lib/preview/lookup.ts +51 -0
- package/src/lib/section/schema.ts +32 -0
- package/src/lib/section/source.ts +77 -0
- package/src/lib/sources.ts +22 -0
- package/src/lib/tree.ts +61 -0
- package/src/lib/urls.ts +17 -0
- package/src/locales/en_us.json +7 -0
- package/src/locales/ru_ru.json +7 -0
- package/src/locales.ts +6 -0
- package/src/types/embeds.ts +54 -0
- package/src/types/index.ts +87 -0
- package/src/types/outline.ts +30 -0
- package/README.md +0 -3
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ComponentProps,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
13
|
+
import { mergeRefs } from '@terrafirmagreg-team/ui/lib/utils/merge-refs';
|
|
14
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
15
|
+
import * as Primitive from './base';
|
|
16
|
+
import { useTOCItems } from '@terrafirmagreg-team/kit/hooks/use-toc';
|
|
17
|
+
|
|
18
|
+
interface SvgResult {
|
|
19
|
+
content: ReactNode;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
d: string;
|
|
23
|
+
itemLineLengths: [number, number][];
|
|
24
|
+
positions: [number, number, number][];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TOCItemsProps extends ComponentProps<'div'> {
|
|
28
|
+
thumbBox?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function TOCItems({
|
|
32
|
+
ref,
|
|
33
|
+
className,
|
|
34
|
+
thumbBox = true,
|
|
35
|
+
children,
|
|
36
|
+
...props
|
|
37
|
+
}: TOCItemsProps) {
|
|
38
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const items = useTOCItems();
|
|
40
|
+
const [svg, setSvg] = useState<SvgResult | null>(null);
|
|
41
|
+
|
|
42
|
+
const onPrint = useCallback(() => {
|
|
43
|
+
const container = containerRef.current;
|
|
44
|
+
if (!container || container.clientHeight === 0) return;
|
|
45
|
+
if (items.length === 0) {
|
|
46
|
+
setSvg(null);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let w = 0;
|
|
51
|
+
let h = 0;
|
|
52
|
+
let d = '';
|
|
53
|
+
const positions: [number, number, number][] = [];
|
|
54
|
+
const output: ReactNode[] = [];
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < items.length; i++) {
|
|
57
|
+
const item = items[i];
|
|
58
|
+
const element = container.querySelector<HTMLElement>(`a[href="${item.url}"]`);
|
|
59
|
+
if (!element) continue;
|
|
60
|
+
const styles = getComputedStyle(element);
|
|
61
|
+
const x = getLineOffset(item.depth) + 0.5;
|
|
62
|
+
const top = element.offsetTop + parseFloat(styles.paddingTop);
|
|
63
|
+
const bottom =
|
|
64
|
+
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
|
|
65
|
+
|
|
66
|
+
w = Math.max(x + 8, w);
|
|
67
|
+
h = Math.max(h, bottom);
|
|
68
|
+
if (i === 0) d += ` M${x} ${top} L${x} ${bottom}`;
|
|
69
|
+
else {
|
|
70
|
+
const [, upperBottom, upperX] = i > 0 ? positions[i - 1] : [0, 0, 0];
|
|
71
|
+
d += ` C ${upperX} ${top - 4} ${x} ${upperBottom + 4} ${x} ${top} L${x} ${bottom}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (item._step !== undefined)
|
|
75
|
+
output.push(
|
|
76
|
+
<g key={i} transform={`translate(${x}, ${(top + bottom) / 2})`}>
|
|
77
|
+
<circle cx="0" cy="0" r="8" className="fill-primary" />
|
|
78
|
+
<text
|
|
79
|
+
x="0"
|
|
80
|
+
y="0"
|
|
81
|
+
textAnchor="middle"
|
|
82
|
+
alignmentBaseline="central"
|
|
83
|
+
dominantBaseline="middle"
|
|
84
|
+
className="fill-primary-foreground font-medium text-xs leading-none font-mono rtl:-scale-x-100"
|
|
85
|
+
>
|
|
86
|
+
{item._step}
|
|
87
|
+
</text>
|
|
88
|
+
</g>,
|
|
89
|
+
);
|
|
90
|
+
positions.push([top, bottom, x]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
output.unshift(
|
|
94
|
+
<path
|
|
95
|
+
key="path"
|
|
96
|
+
d={d}
|
|
97
|
+
className="stroke-primary"
|
|
98
|
+
strokeWidth="1"
|
|
99
|
+
fill="none"
|
|
100
|
+
/>,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const itemLineLengths: [number, number][] = [];
|
|
104
|
+
if (thumbBox) {
|
|
105
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
106
|
+
path.setAttribute('d', d);
|
|
107
|
+
const n = path.getTotalLength();
|
|
108
|
+
for (let i = 0; i < positions.length; i++) {
|
|
109
|
+
const [top, bottom] = positions[i];
|
|
110
|
+
let l =
|
|
111
|
+
i > 0 ? itemLineLengths[i - 1][1] + (top - positions[i - 1][1]) : top;
|
|
112
|
+
while (l < n && path.getPointAtLength(l).y < top) l++;
|
|
113
|
+
itemLineLengths.push([l, l + bottom - top]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setSvg({ content: output, width: w, height: h, d, itemLineLengths, positions });
|
|
118
|
+
}, [items, thumbBox]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const container = containerRef.current;
|
|
122
|
+
if (!container) return;
|
|
123
|
+
const observer = new ResizeObserver(onPrint);
|
|
124
|
+
observer.observe(container);
|
|
125
|
+
onPrint();
|
|
126
|
+
return () => {
|
|
127
|
+
observer.unobserve(container);
|
|
128
|
+
};
|
|
129
|
+
}, [onPrint]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
ref={mergeRefs(containerRef, ref)}
|
|
134
|
+
className={cn('relative flex flex-col', className)}
|
|
135
|
+
{...props}
|
|
136
|
+
>
|
|
137
|
+
{svg && <ThumbTrack computed={svg} thumbBox={thumbBox} />}
|
|
138
|
+
{children}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function TOCEmpty() {
|
|
144
|
+
const t = useTranslations();
|
|
145
|
+
return (
|
|
146
|
+
<div className="rounded-lg border bg-card p-3 text-xs text-muted-foreground">
|
|
147
|
+
{t('No Headings', { note: 'ui.no_headings' })}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ThumbTrack({
|
|
153
|
+
computed,
|
|
154
|
+
thumbBox,
|
|
155
|
+
}: {
|
|
156
|
+
computed: SvgResult;
|
|
157
|
+
thumbBox: boolean;
|
|
158
|
+
}) {
|
|
159
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
160
|
+
const previousRef = useRef<{
|
|
161
|
+
startIdx: number;
|
|
162
|
+
endIdx: number;
|
|
163
|
+
isUp: boolean;
|
|
164
|
+
} | null>(null);
|
|
165
|
+
const tocInfo = Primitive.useTOC();
|
|
166
|
+
|
|
167
|
+
function calculate(
|
|
168
|
+
items: Primitive.TOCItemInfo[],
|
|
169
|
+
previous: { startIdx: number; endIdx: number; isUp: boolean } | null,
|
|
170
|
+
): [CSSProperties, typeof previous] {
|
|
171
|
+
const out: Record<string, string> = {};
|
|
172
|
+
const startIdx = items.findIndex((item) => item.active);
|
|
173
|
+
if (startIdx === -1) return [out, previous];
|
|
174
|
+
const endIdx = items.findLastIndex((item) => item.active);
|
|
175
|
+
|
|
176
|
+
out['--track-top'] = `${computed.positions[startIdx][0]}px`;
|
|
177
|
+
out['--track-bottom'] = `${computed.positions[endIdx][1]}px`;
|
|
178
|
+
|
|
179
|
+
if (thumbBox) {
|
|
180
|
+
let isUp = false;
|
|
181
|
+
if (previous) {
|
|
182
|
+
isUp =
|
|
183
|
+
previous.startIdx > startIdx ||
|
|
184
|
+
previous.endIdx > endIdx ||
|
|
185
|
+
(previous.startIdx === startIdx &&
|
|
186
|
+
previous.endIdx === endIdx &&
|
|
187
|
+
previous.isUp);
|
|
188
|
+
}
|
|
189
|
+
const next = { startIdx, endIdx, isUp };
|
|
190
|
+
out['--offset-distance'] = isUp
|
|
191
|
+
? `${computed.itemLineLengths[startIdx][0]}px`
|
|
192
|
+
: `${computed.itemLineLengths[endIdx][1]}px`;
|
|
193
|
+
out['--opacity'] =
|
|
194
|
+
items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1';
|
|
195
|
+
return [out as CSSProperties, next];
|
|
196
|
+
}
|
|
197
|
+
return [out as CSSProperties, previous];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Primitive.useTOCListener((items) => {
|
|
201
|
+
const element = ref.current;
|
|
202
|
+
if (!element) return;
|
|
203
|
+
const [style, next] = calculate(items, previousRef.current);
|
|
204
|
+
previousRef.current = next;
|
|
205
|
+
for (const [k, v] of Object.entries(style))
|
|
206
|
+
element.style.setProperty(k, v);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
ref={ref}
|
|
212
|
+
className="absolute top-0 inset-s-0 origin-center rtl:-scale-x-100"
|
|
213
|
+
style={{
|
|
214
|
+
width: computed.width,
|
|
215
|
+
height: computed.height,
|
|
216
|
+
...calculate(tocInfo.get(), null)[0],
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<svg
|
|
220
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
221
|
+
viewBox={`0 0 ${computed.width} ${computed.height}`}
|
|
222
|
+
className="absolute transition-[clip-path]"
|
|
223
|
+
style={{
|
|
224
|
+
width: computed.width,
|
|
225
|
+
height: computed.height,
|
|
226
|
+
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{computed.content}
|
|
230
|
+
</svg>
|
|
231
|
+
{thumbBox && (
|
|
232
|
+
<div
|
|
233
|
+
className="absolute left-0 size-1 bg-primary rounded-full [offset-distance:var(--offset-distance,0)] opacity-(--opacity,0) transition-[opacity,offset-distance]"
|
|
234
|
+
style={{ offsetPath: `path("${computed.d}")` }}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const BASE = 8;
|
|
242
|
+
|
|
243
|
+
function getItemOffset(depth: number): number {
|
|
244
|
+
if (depth <= 2) return 20;
|
|
245
|
+
if (depth === 3) return 32;
|
|
246
|
+
return 44;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getLineOffset(depth: number): number {
|
|
250
|
+
if (depth <= 2) return BASE;
|
|
251
|
+
if (depth === 3) return 16;
|
|
252
|
+
return 24;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function TOCItem({
|
|
256
|
+
item,
|
|
257
|
+
...props
|
|
258
|
+
}: Primitive.TOCItemProps & { item: Primitive.TOCItemType }) {
|
|
259
|
+
const items = useTOCItems();
|
|
260
|
+
const { isFirst, isLast, svg } = useMemo(() => {
|
|
261
|
+
const index = items.indexOf(item);
|
|
262
|
+
const isFirst = index === 0;
|
|
263
|
+
const isLast = index === items.length - 1;
|
|
264
|
+
const l1 = getLineOffset(item.depth);
|
|
265
|
+
const l0 = isFirst ? l1 : getLineOffset(items[index - 1].depth);
|
|
266
|
+
const l2 = isLast ? l1 : getLineOffset(items[index + 1].depth);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
isFirst,
|
|
270
|
+
isLast,
|
|
271
|
+
svg: (
|
|
272
|
+
<svg
|
|
273
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
274
|
+
className={cn(
|
|
275
|
+
'absolute -top-1.5 inset-s-0 bottom-0 h-[calc(100%+--spacing(1.5))] -z-1 rtl:-scale-x-100',
|
|
276
|
+
l1 !== l2 && 'h-full bottom-1.5',
|
|
277
|
+
)}
|
|
278
|
+
style={{ width: Math.max(l0, l1) + 9 }}
|
|
279
|
+
>
|
|
280
|
+
{l0 !== l1 && (
|
|
281
|
+
<path
|
|
282
|
+
d={`M ${l0 + 0.5} 0 C ${l0 + 0.5} 8 ${l1 + 0.5} 4 ${l1 + 0.5} 12`}
|
|
283
|
+
stroke="black"
|
|
284
|
+
strokeWidth="1"
|
|
285
|
+
fill="none"
|
|
286
|
+
className="stroke-foreground/10"
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
<line
|
|
290
|
+
x1={l1 + 0.5}
|
|
291
|
+
y1={l0 === l1 ? '6' : '12'}
|
|
292
|
+
x2={l1 + 0.5}
|
|
293
|
+
y2="100%"
|
|
294
|
+
strokeWidth="1"
|
|
295
|
+
className="stroke-foreground/10"
|
|
296
|
+
/>
|
|
297
|
+
{item._step !== undefined && (
|
|
298
|
+
<g transform={`translate(${l1 + 0.5}, ${l1 === l2 ? '3' : '6'})`}>
|
|
299
|
+
<circle cx="0" cy="50%" r="8" className="fill-muted" />
|
|
300
|
+
<text
|
|
301
|
+
x="0"
|
|
302
|
+
y="50%"
|
|
303
|
+
textAnchor="middle"
|
|
304
|
+
alignmentBaseline="central"
|
|
305
|
+
dominantBaseline="middle"
|
|
306
|
+
className="fill-muted-foreground font-medium text-xs leading-none font-mono rtl:-scale-x-100"
|
|
307
|
+
>
|
|
308
|
+
{item._step}
|
|
309
|
+
</text>
|
|
310
|
+
</g>
|
|
311
|
+
)}
|
|
312
|
+
</svg>
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
}, [items, item]);
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Primitive.TOCItem
|
|
319
|
+
href={item.url}
|
|
320
|
+
{...props}
|
|
321
|
+
className={cn(
|
|
322
|
+
'prose prose-sm relative py-1.5 scroll-m-4 text-muted-foreground hover:text-accent-foreground transition-colors wrap-anywhere data-[active=true]:text-primary',
|
|
323
|
+
isFirst && 'pt-0',
|
|
324
|
+
isLast && 'pb-0',
|
|
325
|
+
props.className,
|
|
326
|
+
)}
|
|
327
|
+
style={{ paddingInlineStart: getItemOffset(item.depth), ...props.style }}
|
|
328
|
+
>
|
|
329
|
+
{svg}
|
|
330
|
+
{item.title}
|
|
331
|
+
</Primitive.TOCItem>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, ChevronDown } from 'lucide-react';
|
|
4
|
+
import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '@terrafirmagreg-team/ui/components/ui/dropdown-menu';
|
|
12
|
+
import { useUiComponents } from '@terrafirmagreg-team/ui/provider';
|
|
13
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
14
|
+
import { type DatasetRef, sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
15
|
+
|
|
16
|
+
import type { SectionKey } from '@terrafirmagreg-team/kit/types';
|
|
17
|
+
|
|
18
|
+
export function VersionMenu({
|
|
19
|
+
section,
|
|
20
|
+
datasetId,
|
|
21
|
+
currentRef,
|
|
22
|
+
label,
|
|
23
|
+
versions,
|
|
24
|
+
onSelect,
|
|
25
|
+
}: {
|
|
26
|
+
section?: SectionKey;
|
|
27
|
+
datasetId: string;
|
|
28
|
+
|
|
29
|
+
currentRef: DatasetRef;
|
|
30
|
+
|
|
31
|
+
label?: string;
|
|
32
|
+
versions: { versions: string[]; latest?: string } | null | undefined;
|
|
33
|
+
|
|
34
|
+
onSelect?: (version: string | undefined) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const pinned = currentRef.version;
|
|
38
|
+
const shown = label ?? pinned;
|
|
39
|
+
const text = shown ? `v${shown}` : datasetId;
|
|
40
|
+
if (!versions || versions.versions.length < 2 || (!section && !onSelect)) {
|
|
41
|
+
return <>{text}</>;
|
|
42
|
+
}
|
|
43
|
+
const ordered = [...versions.versions].sort((a, b) =>
|
|
44
|
+
b.localeCompare(a, undefined, { numeric: true }),
|
|
45
|
+
);
|
|
46
|
+
if (versions.latest) {
|
|
47
|
+
const i = ordered.indexOf(versions.latest);
|
|
48
|
+
if (i > 0) ordered.unshift(ordered.splice(i, 1)[0]);
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
|
52
|
+
<DropdownMenuTrigger className="inline-flex items-center gap-0.5 rounded-sm outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
|
53
|
+
{text}
|
|
54
|
+
<ChevronDown className="size-3" />
|
|
55
|
+
</DropdownMenuTrigger>
|
|
56
|
+
<DropdownMenuContent
|
|
57
|
+
side="bottom"
|
|
58
|
+
align="start"
|
|
59
|
+
className="max-h-36 min-w-32"
|
|
60
|
+
>
|
|
61
|
+
<VersionMenuItems
|
|
62
|
+
ordered={ordered}
|
|
63
|
+
latest={versions.latest}
|
|
64
|
+
datasetId={datasetId}
|
|
65
|
+
currentRef={currentRef}
|
|
66
|
+
section={section}
|
|
67
|
+
onSelect={onSelect}
|
|
68
|
+
/>
|
|
69
|
+
</DropdownMenuContent>
|
|
70
|
+
</DropdownMenu>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function VersionMenuItems({
|
|
75
|
+
ordered,
|
|
76
|
+
latest,
|
|
77
|
+
datasetId,
|
|
78
|
+
currentRef,
|
|
79
|
+
section,
|
|
80
|
+
onSelect,
|
|
81
|
+
}: {
|
|
82
|
+
ordered: string[];
|
|
83
|
+
latest: string | undefined;
|
|
84
|
+
datasetId: string;
|
|
85
|
+
currentRef: DatasetRef;
|
|
86
|
+
section?: SectionKey;
|
|
87
|
+
onSelect?: (version: string | undefined) => void;
|
|
88
|
+
}) {
|
|
89
|
+
const { Link } = useUiComponents();
|
|
90
|
+
const t = useTranslations();
|
|
91
|
+
|
|
92
|
+
const current = useLocationSearchParams();
|
|
93
|
+
return ordered.map((version) => {
|
|
94
|
+
const isLatest = version === latest;
|
|
95
|
+
const selected = currentRef.id === datasetId &&
|
|
96
|
+
currentRef.version === (isLatest ? undefined : version);
|
|
97
|
+
const params = new URLSearchParams(current);
|
|
98
|
+
if (isLatest) params.delete('version');
|
|
99
|
+
else params.set('version', version);
|
|
100
|
+
const q = params.toString();
|
|
101
|
+
return (
|
|
102
|
+
<DropdownMenuItem
|
|
103
|
+
key={version}
|
|
104
|
+
closeOnClick
|
|
105
|
+
render={
|
|
106
|
+
section ? (
|
|
107
|
+
<Link
|
|
108
|
+
href={`${sectionUrl(section, datasetId)}${q ? `?${q}` : ''}`}
|
|
109
|
+
/>
|
|
110
|
+
) : undefined
|
|
111
|
+
}
|
|
112
|
+
onClick={
|
|
113
|
+
onSelect ? () => onSelect(isLatest ? undefined : version) : undefined
|
|
114
|
+
}
|
|
115
|
+
className="gap-2 px-2.5 py-1.5 text-xs"
|
|
116
|
+
>
|
|
117
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
118
|
+
<span className="font-medium">v{version}</span>
|
|
119
|
+
{isLatest && (
|
|
120
|
+
<span className="text-2xs text-muted-foreground">
|
|
121
|
+
{t('latest', { note: 'ui.version_latest' })}
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
124
|
+
</span>
|
|
125
|
+
{selected && <CheckIcon className="size-3.5 text-primary" />}
|
|
126
|
+
</DropdownMenuItem>
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import type { Metadata } from 'next';
|
|
4
|
+
import { LangContentSwitch } from '@terrafirmagreg-team/ui/components/blocks/lang/content';
|
|
5
|
+
import {
|
|
6
|
+
generateDatasetLandingParams,
|
|
7
|
+
getDatasetLanding,
|
|
8
|
+
resolveDatasetLanding,
|
|
9
|
+
} from '@terrafirmagreg-team/kit/lib/landing';
|
|
10
|
+
import { sectionDatasets } from '@terrafirmagreg-team/kit/lib/datasets';
|
|
11
|
+
import { sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
12
|
+
import { canonicalUrl } from '@terrafirmagreg-team/kit/lib/urls';
|
|
13
|
+
import type { DatasetLanding, SectionKey } from '@terrafirmagreg-team/kit/types';
|
|
14
|
+
import { localeLoader } from '@terrafirmagreg-team/kit/lib/locale/renderer';
|
|
15
|
+
import { langContent } from '@terrafirmagreg-team/translate/server';
|
|
16
|
+
|
|
17
|
+
interface DatasetPageParams {
|
|
18
|
+
params: Promise<{ slug?: string[] }>;
|
|
19
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createDatasetLandingPage<S extends SectionKey>(
|
|
23
|
+
section: S,
|
|
24
|
+
render: (page: DatasetLanding) => ReactNode,
|
|
25
|
+
) {
|
|
26
|
+
async function Page({ params }: DatasetPageParams) {
|
|
27
|
+
const { slug } = await params;
|
|
28
|
+
const datasets = await sectionDatasets(section);
|
|
29
|
+
const content = langContent(
|
|
30
|
+
slug,
|
|
31
|
+
(s, l) => resolveDatasetLanding(section, s, l, datasets),
|
|
32
|
+
render,
|
|
33
|
+
);
|
|
34
|
+
if (!content) notFound();
|
|
35
|
+
return (
|
|
36
|
+
<LangContentSwitch
|
|
37
|
+
key={[section, ...(slug ?? [])].join('/')}
|
|
38
|
+
content={content}
|
|
39
|
+
loadLang={localeLoader()?.bind(null, { section, slug })}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function generateStaticParams() {
|
|
45
|
+
return generateDatasetLandingParams(section);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function generateMetadata({
|
|
49
|
+
params,
|
|
50
|
+
}: DatasetPageParams): Promise<Metadata> {
|
|
51
|
+
const { slug } = await params;
|
|
52
|
+
const page = getDatasetLanding(
|
|
53
|
+
section,
|
|
54
|
+
slug,
|
|
55
|
+
'en_us',
|
|
56
|
+
await sectionDatasets(section),
|
|
57
|
+
);
|
|
58
|
+
if (!page) notFound();
|
|
59
|
+
return {
|
|
60
|
+
title: page.title,
|
|
61
|
+
description: page.description,
|
|
62
|
+
alternates: {
|
|
63
|
+
canonical: canonicalUrl(
|
|
64
|
+
`${sectionUrl(section)}/${(slug ?? []).join('/')}`,
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { Page, generateStaticParams, generateMetadata };
|
|
71
|
+
}
|
|
72
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createRelativeLink } from '@terrafirmagreg-team/kit/components/relative-link';
|
|
2
|
+
import type { LoaderConfig, LoaderOutput, Page } from 'fumadocs-core/source';
|
|
3
|
+
import { notFound } from 'next/navigation';
|
|
4
|
+
import { LangContentSwitch } from '@terrafirmagreg-team/ui/components/blocks/lang/content';
|
|
5
|
+
import { LinkPreview } from '@terrafirmagreg-team/ui/components/blocks/link/link-preview';
|
|
6
|
+
import { lookupPagePreview } from '@terrafirmagreg-team/kit/lib/preview/lookup';
|
|
7
|
+
import {
|
|
8
|
+
sectionBreadcrumbItems,
|
|
9
|
+
sectionBreadcrumbPreviews,
|
|
10
|
+
type SectionCrumb,
|
|
11
|
+
} from '@terrafirmagreg-team/kit/lib/breadcrumb';
|
|
12
|
+
import { langContent } from '@terrafirmagreg-team/translate/server';
|
|
13
|
+
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
14
|
+
import { readPageSource, translationState } from '@terrafirmagreg-team/translate/server';
|
|
15
|
+
import { resolvePage, type SectionSource } from '@terrafirmagreg-team/kit/lib/page';
|
|
16
|
+
import { localeLoader } from '@terrafirmagreg-team/kit/lib/locale/renderer';
|
|
17
|
+
import { findNeighbour } from 'fumadocs-core/page-tree';
|
|
18
|
+
import type {
|
|
19
|
+
DatasetCatalog,
|
|
20
|
+
SectionKey,
|
|
21
|
+
SectionPage,
|
|
22
|
+
SectionTrees,
|
|
23
|
+
} from '@terrafirmagreg-team/kit/types';
|
|
24
|
+
import type { WorkspacePaginationItems } from '@terrafirmagreg-team/kit/components/workspace';
|
|
25
|
+
import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
|
|
26
|
+
import type { TranslationState } from '@terrafirmagreg-team/translate/server';
|
|
27
|
+
import type {
|
|
28
|
+
ComponentProps,
|
|
29
|
+
ComponentType,
|
|
30
|
+
ReactNode,
|
|
31
|
+
} from 'react';
|
|
32
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
33
|
+
|
|
34
|
+
export interface SectionWorkspaceProps {
|
|
35
|
+
page: SectionPage;
|
|
36
|
+
link: (props: ComponentProps<'a'>) => ReactNode | Promise<ReactNode>;
|
|
37
|
+
contentDir: string;
|
|
38
|
+
copied?: TranslationState;
|
|
39
|
+
crumbs?: SectionCrumb[];
|
|
40
|
+
previews?: Record<string, PagePreviewData>;
|
|
41
|
+
footer?: WorkspacePaginationItems;
|
|
42
|
+
history?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderSectionWorkspace({
|
|
46
|
+
source,
|
|
47
|
+
slug,
|
|
48
|
+
contentDir,
|
|
49
|
+
trees,
|
|
50
|
+
catalog,
|
|
51
|
+
history,
|
|
52
|
+
workspace: Workspace,
|
|
53
|
+
page,
|
|
54
|
+
lang,
|
|
55
|
+
}: {
|
|
56
|
+
source: SectionSource;
|
|
57
|
+
slug: string[] | undefined;
|
|
58
|
+
contentDir: string;
|
|
59
|
+
trees?: SectionTrees;
|
|
60
|
+
catalog: DatasetCatalog;
|
|
61
|
+
history?: boolean;
|
|
62
|
+
workspace: ComponentType<SectionWorkspaceProps>;
|
|
63
|
+
page: SectionPage;
|
|
64
|
+
lang: Lang;
|
|
65
|
+
}) {
|
|
66
|
+
const { defaultLanguage } = loadLocaleRegistry();
|
|
67
|
+
let copied: 'untranslated' | 'partial' | undefined;
|
|
68
|
+
if (lang !== defaultLanguage) {
|
|
69
|
+
const fallback = resolvePage(source, slug, defaultLanguage)?.page as
|
|
70
|
+
| SectionPage
|
|
71
|
+
| undefined;
|
|
72
|
+
if (fallback && fallback !== page) {
|
|
73
|
+
const own = readPageSource(contentDir, page.path);
|
|
74
|
+
const original = readPageSource(contentDir, fallback.path);
|
|
75
|
+
if (own && original) {
|
|
76
|
+
const state = translationState(own, original);
|
|
77
|
+
if (state !== 'ok') copied = state;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const crumbs = sectionBreadcrumbItems(source, page, lang);
|
|
82
|
+
const datasetId = page.url.split('/').filter(Boolean)[1];
|
|
83
|
+
const tree = datasetId ? trees?.[lang]?.[datasetId] : undefined;
|
|
84
|
+
const neighbours = tree ? findNeighbour(tree, page.url) : undefined;
|
|
85
|
+
return (
|
|
86
|
+
<Workspace
|
|
87
|
+
page={page}
|
|
88
|
+
crumbs={crumbs}
|
|
89
|
+
footer={neighbours}
|
|
90
|
+
previews={sectionBreadcrumbPreviews(crumbs, lang, catalog)}
|
|
91
|
+
link={createRelativeLink(
|
|
92
|
+
source as LoaderOutput<LoaderConfig>,
|
|
93
|
+
page as unknown as Page,
|
|
94
|
+
(props) => (
|
|
95
|
+
<LinkPreview
|
|
96
|
+
preview={lookupPagePreview(catalog, props.href, lang)}
|
|
97
|
+
{...props}
|
|
98
|
+
/>
|
|
99
|
+
),
|
|
100
|
+
)}
|
|
101
|
+
contentDir={contentDir}
|
|
102
|
+
copied={copied}
|
|
103
|
+
history={history}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function SectionView({
|
|
109
|
+
section,
|
|
110
|
+
getSource,
|
|
111
|
+
slug,
|
|
112
|
+
contentDir,
|
|
113
|
+
trees,
|
|
114
|
+
catalog,
|
|
115
|
+
history,
|
|
116
|
+
workspace,
|
|
117
|
+
}: {
|
|
118
|
+
section: SectionKey;
|
|
119
|
+
getSource: () => Promise<SectionSource>;
|
|
120
|
+
slug: string[] | undefined;
|
|
121
|
+
contentDir: string;
|
|
122
|
+
trees?: SectionTrees;
|
|
123
|
+
catalog: DatasetCatalog;
|
|
124
|
+
history?: boolean;
|
|
125
|
+
workspace: ComponentType<SectionWorkspaceProps>;
|
|
126
|
+
}) {
|
|
127
|
+
const source = await getSource();
|
|
128
|
+
const content = langContent(
|
|
129
|
+
slug,
|
|
130
|
+
(s, l) => resolvePage(source, s, l),
|
|
131
|
+
(page, lang) =>
|
|
132
|
+
renderSectionWorkspace({
|
|
133
|
+
source,
|
|
134
|
+
slug,
|
|
135
|
+
contentDir,
|
|
136
|
+
trees,
|
|
137
|
+
catalog,
|
|
138
|
+
history,
|
|
139
|
+
workspace,
|
|
140
|
+
page: page as SectionPage,
|
|
141
|
+
lang,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
if (!content) notFound();
|
|
145
|
+
return (
|
|
146
|
+
<LangContentSwitch
|
|
147
|
+
content={content}
|
|
148
|
+
loadLang={localeLoader()?.bind(null, { section, slug })}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|