@terrafirmagreg-team/guide 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 +57 -3
- package/src/components/book-switcher.tsx +52 -0
- package/src/components/mdx/guide-entry.tsx +48 -0
- package/src/components/mdx/multiblock.tsx +86 -0
- package/src/components/tree/children.tsx +75 -0
- package/src/components/tree/index.tsx +127 -0
- package/src/components/views/browser.tsx +133 -0
- package/src/components/views/entry/pages.tsx +188 -0
- package/src/components/views/entry/view.tsx +62 -0
- package/src/components/views/kind/links.tsx +112 -0
- package/src/components/views/kind/media.tsx +168 -0
- package/src/components/views/kind/recipe.tsx +89 -0
- package/src/components/views/kind/table.tsx +140 -0
- package/src/components/views/kind/unknown.tsx +24 -0
- package/src/components/views/page/category-body.tsx +58 -0
- package/src/components/views/page/entry-body.tsx +81 -0
- package/src/components/views/page/overview-body.tsx +99 -0
- package/src/components/views/page/text.tsx +58 -0
- package/src/components/views/page/view.tsx +279 -0
- package/src/components/views/text.tsx +132 -0
- package/src/hooks/use-left-sidebar-tree.ts +28 -0
- package/src/hooks/use-tree.ts +35 -0
- package/src/lib/book/convert.ts +75 -0
- package/src/lib/book/index-rows.ts +136 -0
- package/src/lib/book/index.ts +3 -0
- package/src/lib/book/localize.ts +77 -0
- package/src/lib/book/raw.ts +81 -0
- package/src/lib/books.ts +31 -0
- package/src/lib/breadcrumb.ts +56 -0
- package/src/lib/client/guides.ts +262 -0
- package/src/lib/client-payload/extras.ts +103 -0
- package/src/lib/client-payload/index.ts +113 -0
- package/src/lib/client-payload/links.ts +103 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/data/dto.ts +78 -0
- package/src/lib/data/parse.ts +94 -0
- package/src/lib/dump.ts +6 -0
- package/src/lib/embeds.ts +12 -0
- package/src/lib/format.ts +232 -0
- package/src/lib/links.ts +2 -0
- package/src/lib/markdown/book.ts +74 -0
- package/src/lib/markdown/ctx.ts +28 -0
- package/src/lib/markdown/index.ts +9 -0
- package/src/lib/markdown/page.ts +153 -0
- package/src/lib/markdown/table.ts +36 -0
- package/src/lib/markdown/text.ts +40 -0
- package/src/lib/markdown-page.ts +74 -0
- package/src/lib/messages.ts +14 -0
- package/src/lib/meta.ts +10 -0
- package/src/lib/multiblock/index.ts +9 -0
- package/src/lib/multiblock/url.ts +75 -0
- package/src/lib/names.ts +37 -0
- package/src/lib/page-kind.ts +118 -0
- package/src/lib/paths.ts +54 -0
- package/src/lib/preview.ts +37 -0
- package/src/lib/query.ts +29 -0
- package/src/lib/recipe.ts +31 -0
- package/src/lib/reference/index.ts +41 -0
- package/src/lib/reference/resolver.ts +88 -0
- package/src/lib/search/filter.ts +18 -0
- package/src/lib/search/index.ts +183 -0
- package/src/lib/sprites.ts +18 -0
- package/src/lib/table.ts +11 -0
- package/src/lib/toc.ts +138 -0
- package/src/locales/en_us.json +14 -0
- package/src/locales/ru_ru.json +14 -0
- package/src/locales.ts +6 -0
- package/src/types/index.ts +217 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/guide",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Guide/books section",
|
|
6
|
+
"license": "LGPL-3.0-only",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./components/*": [
|
|
9
|
+
"./src/components/*.tsx",
|
|
10
|
+
"./src/components/*/index.tsx",
|
|
11
|
+
"./src/components/*/index.ts"
|
|
12
|
+
],
|
|
13
|
+
"./hooks/*": [
|
|
14
|
+
"./src/hooks/*.ts",
|
|
15
|
+
"./src/hooks/*/index.ts"
|
|
16
|
+
],
|
|
17
|
+
"./lib/*": [
|
|
18
|
+
"./src/lib/*.ts",
|
|
19
|
+
"./src/lib/*/index.ts"
|
|
20
|
+
],
|
|
21
|
+
"./types/*": "./src/types/*.ts",
|
|
22
|
+
"./types": "./src/types/index.ts",
|
|
23
|
+
"./locales": "./src/locales.ts"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@terrafirmagreg-team/data": "1.2.0",
|
|
27
|
+
"@terrafirmagreg-team/discussions": "1.2.0",
|
|
28
|
+
"@terrafirmagreg-team/dump": "1.2.0",
|
|
29
|
+
"@terrafirmagreg-team/game": "1.2.0",
|
|
30
|
+
"@terrafirmagreg-team/kit": "1.2.0",
|
|
31
|
+
"@terrafirmagreg-team/mdx": "1.2.0",
|
|
32
|
+
"@terrafirmagreg-team/preview": "1.2.0",
|
|
33
|
+
"@terrafirmagreg-team/search": "1.2.0",
|
|
34
|
+
"@terrafirmagreg-team/sites": "1.2.0",
|
|
35
|
+
"@terrafirmagreg-team/translate": "1.2.0",
|
|
36
|
+
"@terrafirmagreg-team/ui": "1.2.0",
|
|
37
|
+
"fumadocs-core": "16.17.8",
|
|
38
|
+
"lucide-react": "^0.577.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"next": ">=15",
|
|
42
|
+
"react": ">=19"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^26.6.5",
|
|
46
|
+
"@types/react": "^19.3.0",
|
|
47
|
+
"@types/react-dom": "^19.3.0"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
|
|
52
|
+
"directory": "packages/guide"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"src"
|
|
59
|
+
]
|
|
6
60
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
import { BookOpen } from 'lucide-react';
|
|
6
|
+
import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
|
|
7
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
8
|
+
import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
9
|
+
import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
|
|
10
|
+
import { guideQueryFromParams } from '@terrafirmagreg-team/guide/lib/query';
|
|
11
|
+
import { listGuideBooks } from '@terrafirmagreg-team/guide/lib/books';
|
|
12
|
+
import { guideHref } from '@terrafirmagreg-team/guide/lib/links';
|
|
13
|
+
import { parseSectionLocation } from '@terrafirmagreg-team/sites/location';
|
|
14
|
+
import { Switcher } from '@terrafirmagreg-team/ui/components/blocks/nav/switcher';
|
|
15
|
+
|
|
16
|
+
function oneLineLanding(text: string | undefined) {
|
|
17
|
+
if (!text) return undefined;
|
|
18
|
+
const stripped = text.replace(/\$\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
|
|
19
|
+
return stripped || undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function GuideBookSwitcher() {
|
|
23
|
+
const t = useTranslations();
|
|
24
|
+
const { dataset: datasetId } = parseSectionLocation(usePathname());
|
|
25
|
+
const lang = useResolvedLang();
|
|
26
|
+
const params = useLocationSearchParams();
|
|
27
|
+
const { bookId } = guideQueryFromParams(params);
|
|
28
|
+
const version = params.get('version') ?? undefined;
|
|
29
|
+
const dataset = useMemo(
|
|
30
|
+
() => (datasetId ? { id: datasetId, version } : undefined),
|
|
31
|
+
[datasetId, version],
|
|
32
|
+
);
|
|
33
|
+
const books = useAsyncValue(
|
|
34
|
+
dataset ? () => listGuideBooks(dataset, lang) : undefined,
|
|
35
|
+
[dataset, lang],
|
|
36
|
+
);
|
|
37
|
+
if (!books || books.length < 2 || !dataset) return null;
|
|
38
|
+
return (
|
|
39
|
+
<Switcher
|
|
40
|
+
options={books.map((book) => ({
|
|
41
|
+
value: book.bookId,
|
|
42
|
+
title: book.name,
|
|
43
|
+
description: oneLineLanding(book.landingText),
|
|
44
|
+
icon: <BookOpen className="size-4" />,
|
|
45
|
+
url: guideHref(dataset, lang, book.bookId),
|
|
46
|
+
}))}
|
|
47
|
+
selected={bookId}
|
|
48
|
+
placeholder={t('Select book', { note: 'ui.select_book' })}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { parseSectionLocation } from '@terrafirmagreg-team/sites/location';
|
|
6
|
+
import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
|
|
7
|
+
import { useEntryContext } from '@terrafirmagreg-team/game/hooks/use-entry-context';
|
|
8
|
+
import { ResolvedLangContext } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
9
|
+
import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
|
|
10
|
+
import { findClientGuideEntry } from '@terrafirmagreg-team/guide/lib/client/guides';
|
|
11
|
+
import type { GuideDirectiveProps } from '@terrafirmagreg-team/game/types/entry';
|
|
12
|
+
|
|
13
|
+
export function GuideEntry({
|
|
14
|
+
id,
|
|
15
|
+
dataset: datasetProp,
|
|
16
|
+
name,
|
|
17
|
+
modId,
|
|
18
|
+
bookPath,
|
|
19
|
+
entry: entryOpt,
|
|
20
|
+
...rest
|
|
21
|
+
}: GuideDirectiveProps) {
|
|
22
|
+
const ctx = useEntryContext();
|
|
23
|
+
const pathname = usePathname();
|
|
24
|
+
const locId = parseSectionLocation(pathname).dataset;
|
|
25
|
+
const dataset =
|
|
26
|
+
datasetProp ?? ctx?.dataset ?? (locId ? { id: locId } : undefined);
|
|
27
|
+
const lang = useContext(ResolvedLangContext)?.lang;
|
|
28
|
+
const hit = useAsyncValue(
|
|
29
|
+
dataset && lang && id
|
|
30
|
+
? () => findClientGuideEntry(dataset, lang, id)
|
|
31
|
+
: false,
|
|
32
|
+
[dataset, lang, id],
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<Entry
|
|
36
|
+
kind="guide"
|
|
37
|
+
id={hit?.entry.id ?? id}
|
|
38
|
+
dataset={dataset}
|
|
39
|
+
name={name ?? hit?.entry.name}
|
|
40
|
+
icon={hit?.entry.icon}
|
|
41
|
+
modId={modId ?? hit?.modId}
|
|
42
|
+
bookPath={bookPath ?? hit?.bookPath}
|
|
43
|
+
entry={entryOpt ?? hit?.entry.id}
|
|
44
|
+
{...rest}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useContext, useMemo } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import {
|
|
6
|
+
type DatasetRef,
|
|
7
|
+
parseSectionLocation,
|
|
8
|
+
} from '@terrafirmagreg-team/sites/location';
|
|
9
|
+
import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
|
|
10
|
+
import { useDumpLocation } from '@terrafirmagreg-team/game/hooks/use-dump-location';
|
|
11
|
+
import { ResolvedLangContext } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
12
|
+
import { createDumpAssetResolver } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
|
|
13
|
+
import { asMultiblock } from '@terrafirmagreg-team/guide/lib/meta';
|
|
14
|
+
import { glbFor } from '@terrafirmagreg-team/guide/lib/multiblock/url';
|
|
15
|
+
import { loadMultiblockNameIndex } from '@terrafirmagreg-team/guide/lib/client/guides';
|
|
16
|
+
import { multiblockKey } from '@terrafirmagreg-team/guide/lib/multiblock';
|
|
17
|
+
import { MultiblockVisual } from '@terrafirmagreg-team/guide/components/views/kind/media';
|
|
18
|
+
import type { GuideMultiblock } from '@terrafirmagreg-team/guide/types';
|
|
19
|
+
|
|
20
|
+
export type MdxMultiblockProps = {
|
|
21
|
+
id?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
spec?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
dataset?: DatasetRef;
|
|
26
|
+
title?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function MdxMultiblock({
|
|
30
|
+
id,
|
|
31
|
+
name,
|
|
32
|
+
spec,
|
|
33
|
+
label,
|
|
34
|
+
dataset: datasetProp,
|
|
35
|
+
title,
|
|
36
|
+
}: MdxMultiblockProps) {
|
|
37
|
+
const pathname = usePathname();
|
|
38
|
+
const locId = parseSectionLocation(pathname).dataset;
|
|
39
|
+
const dataset = useMemo<DatasetRef>(
|
|
40
|
+
() => datasetProp ?? { id: locId ?? '' },
|
|
41
|
+
[datasetProp, locId],
|
|
42
|
+
);
|
|
43
|
+
const lang = useContext(ResolvedLangContext)?.lang;
|
|
44
|
+
const dumpLoc = useDumpLocation(dataset);
|
|
45
|
+
const resolveResource = useMemo(
|
|
46
|
+
() => createDumpAssetResolver(dataset),
|
|
47
|
+
[dataset],
|
|
48
|
+
);
|
|
49
|
+
const namedSpec = useAsyncValue(
|
|
50
|
+
dataset && lang && name
|
|
51
|
+
? async () =>
|
|
52
|
+
(await loadMultiblockNameIndex(dataset, lang))[
|
|
53
|
+
name.trim().toLowerCase()
|
|
54
|
+
]
|
|
55
|
+
: false,
|
|
56
|
+
[dataset, lang, name],
|
|
57
|
+
);
|
|
58
|
+
const value = useMemo<string | GuideMultiblock | undefined>(() => {
|
|
59
|
+
if (spec) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(spec) as GuideMultiblock;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (name) return namedSpec;
|
|
67
|
+
return id;
|
|
68
|
+
}, [id, name, spec, namedSpec]);
|
|
69
|
+
const key = multiblockKey(asMultiblock(value));
|
|
70
|
+
const src = useAsyncValue(
|
|
71
|
+
dataset && key
|
|
72
|
+
? () => glbFor(dataset, '', asMultiblock(value), lang)
|
|
73
|
+
: false,
|
|
74
|
+
[dataset, key, lang, dumpLoc],
|
|
75
|
+
);
|
|
76
|
+
const glbs = src && key ? { [key]: src } : undefined;
|
|
77
|
+
return (
|
|
78
|
+
<MultiblockVisual
|
|
79
|
+
value={value}
|
|
80
|
+
title={title ?? label ?? id ?? name}
|
|
81
|
+
glbs={glbs}
|
|
82
|
+
resolveResource={resolveResource}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
3
|
+
import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
|
|
4
|
+
import { encodeQueryValue } from '@terrafirmagreg-team/translate/lib/url';
|
|
5
|
+
import {
|
|
6
|
+
type DatasetRef,
|
|
7
|
+
sectionUrl,
|
|
8
|
+
versionSuffix,
|
|
9
|
+
} from '@terrafirmagreg-team/sites/location';
|
|
10
|
+
import { bookOptionId, guideCategoryHref } from '@terrafirmagreg-team/guide/lib/paths';
|
|
11
|
+
import type { GuideIndex } from '@terrafirmagreg-team/guide/types';
|
|
12
|
+
import type { TaggedReference } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
|
|
13
|
+
|
|
14
|
+
function nodeIcon(
|
|
15
|
+
dataset: DatasetRef,
|
|
16
|
+
icon: TaggedReference | undefined,
|
|
17
|
+
name: string,
|
|
18
|
+
): React.ReactNode {
|
|
19
|
+
if (!icon?.id) return undefined;
|
|
20
|
+
return createElement(
|
|
21
|
+
'span',
|
|
22
|
+
{
|
|
23
|
+
'data-icon': true,
|
|
24
|
+
className:
|
|
25
|
+
'inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden',
|
|
26
|
+
},
|
|
27
|
+
createElement(Entry, {
|
|
28
|
+
dataset,
|
|
29
|
+
id: icon.id,
|
|
30
|
+
kind: icon.kind,
|
|
31
|
+
name,
|
|
32
|
+
size: 14,
|
|
33
|
+
href: false,
|
|
34
|
+
tooltip: false,
|
|
35
|
+
missing: 'hide',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function guideTreeChildren(
|
|
41
|
+
dataset: DatasetRef,
|
|
42
|
+
index: GuideIndex,
|
|
43
|
+
): PageTree.Node[] {
|
|
44
|
+
const bookId =
|
|
45
|
+
index.bookId ??
|
|
46
|
+
(index.modId && index.bookPath
|
|
47
|
+
? bookOptionId(index.modId, index.bookPath)
|
|
48
|
+
: undefined);
|
|
49
|
+
return index.categories.map((category): PageTree.Folder => ({
|
|
50
|
+
type: 'folder',
|
|
51
|
+
name: category.name,
|
|
52
|
+
icon: nodeIcon(dataset, category.icon, category.name),
|
|
53
|
+
index: {
|
|
54
|
+
type: 'page',
|
|
55
|
+
name: category.name,
|
|
56
|
+
url: guideCategoryHref(
|
|
57
|
+
dataset,
|
|
58
|
+
undefined,
|
|
59
|
+
index.modId,
|
|
60
|
+
index.bookPath ?? '',
|
|
61
|
+
category,
|
|
62
|
+
),
|
|
63
|
+
$id: `${dataset.id}/${dataset.version ?? ''}/${index.modId}/${index.bookPath}/${category.id}#index`,
|
|
64
|
+
},
|
|
65
|
+
$id: `${dataset.id}/${dataset.version ?? ''}/${index.modId}/${index.bookPath}/${category.id}`,
|
|
66
|
+
children: category.entries.map((entry): PageTree.Item => ({
|
|
67
|
+
type: 'page',
|
|
68
|
+
name: entry.name,
|
|
69
|
+
url: `${sectionUrl('guide', dataset.id)}?book=${encodeQueryValue(bookId ?? '')}&entry=${encodeQueryValue(entry.id)}${versionSuffix(dataset)}`,
|
|
70
|
+
icon: nodeIcon(dataset, entry.icon, entry.name),
|
|
71
|
+
$id: `${dataset.id}/${dataset.version ?? ''}/${index.modId}/${index.bookPath}/${category.id}/${entry.id}`,
|
|
72
|
+
})),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
4
|
+
import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
|
|
5
|
+
import { loadMergedLangMap } from '@terrafirmagreg-team/dump/lib/dump/assets/lang/load';
|
|
6
|
+
import { asRecord, asString } from '@terrafirmagreg-team/dump/lib/dump/contract/read';
|
|
7
|
+
import { loadDatasetDump } from '@terrafirmagreg-team/dump/lib/dump/mods/load';
|
|
8
|
+
import { DUMP_POINTER_TTL } from '@terrafirmagreg-team/dump/lib/dump/json-reader';
|
|
9
|
+
import { createAsyncCache } from '@terrafirmagreg-team/data/lib/async-cache';
|
|
10
|
+
import { clientDumpJson } from '@terrafirmagreg-team/dump/lib/dump/client/dump';
|
|
11
|
+
import { keyTreeNode } from '@terrafirmagreg-team/translate/server';
|
|
12
|
+
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
13
|
+
import { encodeQueryValue } from '@terrafirmagreg-team/translate/lib/url';
|
|
14
|
+
import {
|
|
15
|
+
type DatasetRef,
|
|
16
|
+
sectionUrl,
|
|
17
|
+
versionSuffix,
|
|
18
|
+
} from '@terrafirmagreg-team/sites/location';
|
|
19
|
+
import { bookOptionId } from '@terrafirmagreg-team/guide/lib/paths';
|
|
20
|
+
import { bookSlug, toBook } from '@terrafirmagreg-team/guide/lib/book';
|
|
21
|
+
import { sectionDatasets } from '@terrafirmagreg-team/kit/lib/datasets';
|
|
22
|
+
import type { GuideBookJson, GuideBookRef } from '@terrafirmagreg-team/guide/lib/data/dto';
|
|
23
|
+
import type { SectionTrees } from '@terrafirmagreg-team/kit/types';
|
|
24
|
+
|
|
25
|
+
function bookIcon(
|
|
26
|
+
dataset: DatasetRef,
|
|
27
|
+
raw: GuideBookJson | undefined,
|
|
28
|
+
): React.ReactNode {
|
|
29
|
+
const cover = asRecord(raw?.cover);
|
|
30
|
+
const coverId = asString(cover?.id) ?? undefined;
|
|
31
|
+
if (!coverId) return undefined;
|
|
32
|
+
return createElement(
|
|
33
|
+
'span',
|
|
34
|
+
{
|
|
35
|
+
'data-icon': true,
|
|
36
|
+
className:
|
|
37
|
+
'inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden',
|
|
38
|
+
},
|
|
39
|
+
createElement(Entry, {
|
|
40
|
+
dataset,
|
|
41
|
+
id: coverId,
|
|
42
|
+
kind: 'item',
|
|
43
|
+
size: 14,
|
|
44
|
+
href: false,
|
|
45
|
+
tooltip: false,
|
|
46
|
+
missing: 'hide',
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bookLeaf(
|
|
52
|
+
dataset: DatasetRef,
|
|
53
|
+
ref: GuideBookRef,
|
|
54
|
+
slug: string,
|
|
55
|
+
raw: GuideBookJson | undefined,
|
|
56
|
+
map: Record<string, string>,
|
|
57
|
+
): PageTree.Item {
|
|
58
|
+
const book = toBook(raw, ref, slug, map);
|
|
59
|
+
const bookId = bookOptionId(ref.modId, ref.bookPath);
|
|
60
|
+
return keyTreeNode({
|
|
61
|
+
type: 'page',
|
|
62
|
+
name: book.name,
|
|
63
|
+
url: `${sectionUrl('guide', dataset.id)}?book=${encodeQueryValue(bookId)}${versionSuffix(dataset)}`,
|
|
64
|
+
icon: bookIcon(dataset, raw),
|
|
65
|
+
$id: `${dataset.id}/${dataset.version ?? ''}/book/${bookId}`,
|
|
66
|
+
}) as PageTree.Item;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function datasetBookLeaves(
|
|
70
|
+
dataset: DatasetRef,
|
|
71
|
+
langMap: Record<string, string>,
|
|
72
|
+
): Promise<PageTree.Node[]> {
|
|
73
|
+
const dump = await loadDatasetDump(dataset);
|
|
74
|
+
if (!dump) return [];
|
|
75
|
+
const taken = new Set<string>();
|
|
76
|
+
const leaves: PageTree.Node[] = [];
|
|
77
|
+
for (const mod of dump.mods) {
|
|
78
|
+
const raws = await Promise.all(
|
|
79
|
+
mod.books.map((ref) =>
|
|
80
|
+
clientDumpJson(dataset, `${ref.dir}/book.json`).then(
|
|
81
|
+
(raw) => asRecord(raw) as GuideBookJson | undefined,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
for (const [i, ref] of mod.books.entries()) {
|
|
86
|
+
const raw = raws[i];
|
|
87
|
+
if (!raw) continue;
|
|
88
|
+
let slug = bookSlug(raw, ref);
|
|
89
|
+
if (taken.has(slug)) {
|
|
90
|
+
let n = 2;
|
|
91
|
+
while (taken.has(`${slug}-${n}`)) n += 1;
|
|
92
|
+
slug = `${slug}-${n}`;
|
|
93
|
+
}
|
|
94
|
+
taken.add(slug);
|
|
95
|
+
leaves.push(bookLeaf(dataset, ref, slug, raw, langMap));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return leaves;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const guideTreesCache = createAsyncCache(
|
|
102
|
+
async (): Promise<SectionTrees> => buildGuideTrees(),
|
|
103
|
+
() => 'guide-trees',
|
|
104
|
+
DUMP_POINTER_TTL,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
export function guideTrees(): Promise<SectionTrees> {
|
|
108
|
+
return guideTreesCache();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function buildGuideTrees(): Promise<SectionTrees> {
|
|
112
|
+
const { defaultLanguage } = loadLocaleRegistry();
|
|
113
|
+
const datasets = await sectionDatasets('guide');
|
|
114
|
+
const trees: SectionTrees = {};
|
|
115
|
+
for (const { id, title: datasetTitle, languages } of datasets) {
|
|
116
|
+
for (const lang of languages ?? [defaultLanguage]) {
|
|
117
|
+
const map = await loadMergedLangMap({ id }, lang);
|
|
118
|
+
(trees[lang] ??= {})[id] = {
|
|
119
|
+
name: datasetTitle ?? id,
|
|
120
|
+
children: await datasetBookLeaves({ id }, map),
|
|
121
|
+
$id: `guide/${id}/${lang}`,
|
|
122
|
+
} satisfies PageTree.Root;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return trees;
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useMemo } from 'react';
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
|
|
5
|
+
import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
|
|
6
|
+
import { useVersionedDataset } from '@terrafirmagreg-team/kit/hooks/use-versioned-dataset';
|
|
7
|
+
import { useLangMap } from '@terrafirmagreg-team/game/hooks/use-lang-map';
|
|
8
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
9
|
+
import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
|
|
10
|
+
import {
|
|
11
|
+
Workspace,
|
|
12
|
+
WorkspaceBody,
|
|
13
|
+
} from '@terrafirmagreg-team/kit/components/workspace';
|
|
14
|
+
import { GuidePageView } from '@terrafirmagreg-team/guide/components/views/page/view';
|
|
15
|
+
import {
|
|
16
|
+
firstGuideEntryId,
|
|
17
|
+
guideQueryFromParams,
|
|
18
|
+
} from '@terrafirmagreg-team/guide/lib/query';
|
|
19
|
+
import { loadClientGuidePayload } from '@terrafirmagreg-team/guide/lib/client-payload';
|
|
20
|
+
import { listGuideBooks } from '@terrafirmagreg-team/guide/lib/books';
|
|
21
|
+
import { loadClientGuideBook } from '@terrafirmagreg-team/guide/lib/client/guides';
|
|
22
|
+
import { guideHref } from '@terrafirmagreg-team/guide/lib/links';
|
|
23
|
+
import { guideConfig } from '@terrafirmagreg-team/guide/lib/config';
|
|
24
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
25
|
+
import type { GuideMessages } from '@terrafirmagreg-team/guide/types';
|
|
26
|
+
|
|
27
|
+
export function GuideBrowser({
|
|
28
|
+
dataset: id,
|
|
29
|
+
datasetTitle,
|
|
30
|
+
lang,
|
|
31
|
+
versions,
|
|
32
|
+
}: {
|
|
33
|
+
dataset: string;
|
|
34
|
+
datasetTitle: string;
|
|
35
|
+
lang: Lang;
|
|
36
|
+
versions?: readonly string[];
|
|
37
|
+
}) {
|
|
38
|
+
const t = useTranslations();
|
|
39
|
+
const router = useRouter();
|
|
40
|
+
const params = useLocationSearchParams();
|
|
41
|
+
const dataset = useVersionedDataset(id, { versions }) ?? { id };
|
|
42
|
+
const { bookId, entryId, categoryId } = useMemo(
|
|
43
|
+
() => guideQueryFromParams(params),
|
|
44
|
+
[params],
|
|
45
|
+
);
|
|
46
|
+
const books = useAsyncValue(
|
|
47
|
+
() => listGuideBooks(dataset, lang),
|
|
48
|
+
[dataset, lang],
|
|
49
|
+
);
|
|
50
|
+
const langMap = useLangMap(dataset, lang);
|
|
51
|
+
const messages = useMemo<GuideMessages>(
|
|
52
|
+
() => ({
|
|
53
|
+
'guide.related': t('Related', { note: 'guide.related' }),
|
|
54
|
+
'guide.objective': t('Objective', { note: 'guide.objective' }),
|
|
55
|
+
'guide.link': t('Open', { note: 'guide.link' }),
|
|
56
|
+
'guide.quest': t('Quest', { note: 'guide.quest' }),
|
|
57
|
+
'guide.recipe_empty': t('No recipes', { note: 'guide.recipe_empty' }),
|
|
58
|
+
'guide.entity_unavailable': t('Entity preview unavailable', {
|
|
59
|
+
note: 'guide.entity_unavailable',
|
|
60
|
+
}),
|
|
61
|
+
'guide.entry_missing': t('Entry not found', {
|
|
62
|
+
note: 'guide.entry_missing',
|
|
63
|
+
}),
|
|
64
|
+
'guide.entry_count': t('{count} entries', {
|
|
65
|
+
note: 'guide.entry_count',
|
|
66
|
+
}),
|
|
67
|
+
'guide.page_count': t('{count} pages', { note: 'guide.page_count' }),
|
|
68
|
+
}),
|
|
69
|
+
[t],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const canonical = useAsyncValue(
|
|
73
|
+
books && !bookId
|
|
74
|
+
? async () => {
|
|
75
|
+
const book = books[0];
|
|
76
|
+
if (!book) return undefined;
|
|
77
|
+
const index = await loadClientGuideBook(dataset, lang, book.bookId);
|
|
78
|
+
const first = index ? firstGuideEntryId(index) : undefined;
|
|
79
|
+
return guideHref(dataset, lang, book.bookId, first);
|
|
80
|
+
}
|
|
81
|
+
: undefined,
|
|
82
|
+
[books, bookId, dataset, lang],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (canonical) router.replace(canonical);
|
|
87
|
+
}, [canonical, router]);
|
|
88
|
+
|
|
89
|
+
const payload = useAsyncValue(
|
|
90
|
+
langMap && bookId
|
|
91
|
+
? () =>
|
|
92
|
+
loadClientGuidePayload(
|
|
93
|
+
dataset,
|
|
94
|
+
lang,
|
|
95
|
+
bookId,
|
|
96
|
+
entryId,
|
|
97
|
+
categoryId,
|
|
98
|
+
langMap,
|
|
99
|
+
messages,
|
|
100
|
+
)
|
|
101
|
+
: undefined,
|
|
102
|
+
[dataset, lang, bookId, entryId, categoryId, langMap, messages],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (payload?.title) {
|
|
107
|
+
document.title = `${payload.title} · ${datasetTitle} | ${guideConfig().appName}`;
|
|
108
|
+
}
|
|
109
|
+
}, [payload?.title, datasetTitle]);
|
|
110
|
+
|
|
111
|
+
if (!bookId || !payload || !langMap) {
|
|
112
|
+
return (
|
|
113
|
+
<Workspace toc={[]}>
|
|
114
|
+
<WorkspaceBody className="flex flex-col gap-3">
|
|
115
|
+
<Skeleton className="h-3.5 w-44" />
|
|
116
|
+
<Skeleton className="mt-1 h-8 w-2/3 max-w-sm" />
|
|
117
|
+
<Skeleton className="h-4 w-full max-w-md" />
|
|
118
|
+
<Skeleton className="h-4 w-4/5 max-w-sm" />
|
|
119
|
+
<div className="mt-6 flex flex-col gap-2.5">
|
|
120
|
+
<Skeleton className="h-4 w-full" />
|
|
121
|
+
<Skeleton className="h-4 w-[92%]" />
|
|
122
|
+
<Skeleton className="h-4 w-[96%]" />
|
|
123
|
+
<Skeleton className="h-32 w-full" />
|
|
124
|
+
<Skeleton className="h-4 w-[88%]" />
|
|
125
|
+
<Skeleton className="h-4 w-3/4" />
|
|
126
|
+
</div>
|
|
127
|
+
</WorkspaceBody>
|
|
128
|
+
</Workspace>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return <GuidePageView payload={payload} langMap={langMap} />;
|
|
132
|
+
}
|
|
133
|
+
|