create-eziwiki 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/bin/create-eziwiki.mjs +83 -0
- package/lib/scaffold.mjs +230 -0
- package/lib/scaffold.test.mjs +270 -0
- package/package.json +38 -0
- package/template/README.md +39 -0
- package/template/app/[...slug]/page.tsx +162 -0
- package/template/app/error.tsx +77 -0
- package/template/app/global-error.tsx +76 -0
- package/template/app/globals.css +44 -0
- package/template/app/graph/page.tsx +62 -0
- package/template/app/layout.tsx +164 -0
- package/template/app/not-found.tsx +48 -0
- package/template/app/page.tsx +34 -0
- package/template/app/robots.ts +20 -0
- package/template/app/sitemap.ts +48 -0
- package/template/components/ThemeToggle.tsx +77 -0
- package/template/components/graph/GraphView.tsx +156 -0
- package/template/components/layout/Backlinks.tsx +42 -0
- package/template/components/layout/Breadcrumb.tsx +88 -0
- package/template/components/layout/MobileMenu.tsx +299 -0
- package/template/components/layout/NavigationButtons.tsx +87 -0
- package/template/components/layout/PageLayout.tsx +89 -0
- package/template/components/layout/Sidebar.tsx +376 -0
- package/template/components/layout/TabBar.tsx +312 -0
- package/template/components/layout/TabBarSkeleton.tsx +12 -0
- package/template/components/layout/TabInitializer.tsx +99 -0
- package/template/components/layout/TableOfContents.tsx +138 -0
- package/template/components/markdown/CodeCopy.tsx +65 -0
- package/template/components/markdown/MarkdownContent.tsx +38 -0
- package/template/components/markdown/PageTransition.tsx +56 -0
- package/template/components/providers/UrlMapProvider.tsx +68 -0
- package/template/components/search/SearchDialog.tsx +286 -0
- package/template/components/search/SearchTrigger.tsx +41 -0
- package/template/content/guides/_meta.json +4 -0
- package/template/content/guides/writing.md +82 -0
- package/template/content/intro.md +29 -0
- package/template/eslintignore +7 -0
- package/template/eslintrc.js +40 -0
- package/template/gitignore +40 -0
- package/template/lib/basePath.test.ts +120 -0
- package/template/lib/basePath.ts +108 -0
- package/template/lib/cache.ts +36 -0
- package/template/lib/content/registry.ts +311 -0
- package/template/lib/content/resolver.ts +109 -0
- package/template/lib/graph/build.ts +214 -0
- package/template/lib/graph/layout.test.ts +189 -0
- package/template/lib/graph/layout.ts +247 -0
- package/template/lib/markdown/languages.test.ts +85 -0
- package/template/lib/markdown/languages.ts +103 -0
- package/template/lib/markdown/rehype-plugins.ts +240 -0
- package/template/lib/markdown/remark-wikilink.ts +141 -0
- package/template/lib/markdown/render.ts +175 -0
- package/template/lib/markdown/wikilink.test.ts +91 -0
- package/template/lib/markdown/wikilink.ts +85 -0
- package/template/lib/navigation/auto.ts +227 -0
- package/template/lib/navigation/builder.test.ts +129 -0
- package/template/lib/navigation/builder.ts +122 -0
- package/template/lib/navigation/hash.ts +32 -0
- package/template/lib/navigation/url.test.ts +88 -0
- package/template/lib/navigation/url.ts +108 -0
- package/template/lib/navigation/urlMap.ts +81 -0
- package/template/lib/payload/schema.ts +81 -0
- package/template/lib/payload/types.ts +105 -0
- package/template/lib/payload/validator.ts +56 -0
- package/template/lib/search/build.ts +204 -0
- package/template/lib/search/client.ts +190 -0
- package/template/lib/search/tokenizer.test.ts +60 -0
- package/template/lib/search/tokenizer.ts +83 -0
- package/template/lib/search/types.ts +40 -0
- package/template/lib/site.ts +86 -0
- package/template/lib/store/searchStore.ts +26 -0
- package/template/lib/store/tabStore.ts +313 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.js +43 -0
- package/template/package-lock.json +9933 -0
- package/template/package.json +69 -0
- package/template/payload/config.ts +34 -0
- package/template/postcss.config.js +6 -0
- package/template/prettierignore +7 -0
- package/template/prettierrc +8 -0
- package/template/public/favicon.svg +10 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
- package/template/public/images/.gitkeep +0 -0
- package/template/scripts/build-search-index.ts +36 -0
- package/template/scripts/check-links.ts +40 -0
- package/template/scripts/show-urls.ts +48 -0
- package/template/scripts/validate-payload.ts +30 -0
- package/template/styles/markdown.css +167 -0
- package/template/styles/theme.css +65 -0
- package/template/tailwind.config.ts +156 -0
- package/template/tsconfig.json +32 -0
- package/template/vitest.config.ts +30 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PageTransition } from '@/components/markdown/PageTransition';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Home page - shows empty state for New Tab
|
|
5
|
+
*/
|
|
6
|
+
export default function Home() {
|
|
7
|
+
return (
|
|
8
|
+
<PageTransition>
|
|
9
|
+
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
|
10
|
+
<div className="max-w-md">
|
|
11
|
+
<svg
|
|
12
|
+
className="w-20 h-20 mx-auto mb-6 text-gray-300 dark:text-gray-700"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
viewBox="0 0 24 24"
|
|
16
|
+
>
|
|
17
|
+
<path
|
|
18
|
+
strokeLinecap="round"
|
|
19
|
+
strokeLinejoin="round"
|
|
20
|
+
strokeWidth={1.5}
|
|
21
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
|
25
|
+
Welcome to Documentation
|
|
26
|
+
</h2>
|
|
27
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
28
|
+
Select a page from the sidebar to get started, or click the + button to open a new tab.
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</PageTransition>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MetadataRoute } from 'next';
|
|
2
|
+
import { payload } from '@/payload/config';
|
|
3
|
+
import { fileUrl } from '@/lib/basePath';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate robots.txt file
|
|
7
|
+
* Controls how search engines crawl and index the site
|
|
8
|
+
*
|
|
9
|
+
* @returns Robots configuration
|
|
10
|
+
*/
|
|
11
|
+
export default function robots(): MetadataRoute.Robots {
|
|
12
|
+
return {
|
|
13
|
+
rules: {
|
|
14
|
+
userAgent: '*',
|
|
15
|
+
allow: '/',
|
|
16
|
+
disallow: ['/api/', '/_next/'],
|
|
17
|
+
},
|
|
18
|
+
sitemap: fileUrl('/sitemap.xml', payload.global.baseUrl),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { MetadataRoute } from 'next';
|
|
2
|
+
import { getSite } from '@/lib/site';
|
|
3
|
+
import { docPathToUrl } from '@/lib/navigation/url';
|
|
4
|
+
import { pageUrl } from '@/lib/basePath';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generates the sitemap for every published page.
|
|
8
|
+
*
|
|
9
|
+
* Entries are derived from the content registry rather than from navigation,
|
|
10
|
+
* so documents reachable only by direct link are still discoverable. Pages
|
|
11
|
+
* marked `hidden` are excluded — they are deliberately unlisted, and
|
|
12
|
+
* advertising them in the sitemap would defeat that.
|
|
13
|
+
*
|
|
14
|
+
* Under the `hash` URL strategy the emitted URLs are hashes, which search
|
|
15
|
+
* engines can crawl but which carry no descriptive value; the `path` strategy
|
|
16
|
+
* is the one to use if organic search matters.
|
|
17
|
+
*
|
|
18
|
+
* @returns Sitemap entries for the home page and all visible content
|
|
19
|
+
*/
|
|
20
|
+
export default function sitemap(): MetadataRoute.Sitemap {
|
|
21
|
+
const { global, urlMap, docPaths, hiddenPaths } = getSite();
|
|
22
|
+
const lastModified = new Date();
|
|
23
|
+
|
|
24
|
+
const homeEntry: MetadataRoute.Sitemap[0] = {
|
|
25
|
+
url: pageUrl('', global.baseUrl),
|
|
26
|
+
lastModified,
|
|
27
|
+
changeFrequency: 'weekly',
|
|
28
|
+
priority: 1,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const contentEntries = docPaths.flatMap((path): MetadataRoute.Sitemap => {
|
|
32
|
+
if (hiddenPaths.has(path)) return [];
|
|
33
|
+
|
|
34
|
+
const url = docPathToUrl(urlMap, path);
|
|
35
|
+
if (!url) return [];
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
url: pageUrl(url, global.baseUrl),
|
|
40
|
+
lastModified,
|
|
41
|
+
changeFrequency: 'weekly',
|
|
42
|
+
priority: 0.8,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return [homeEntry, ...contentEntries];
|
|
48
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ThemeToggleProps {
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ThemeToggle({ className = 'w-5 h-5' }: ThemeToggleProps) {
|
|
10
|
+
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
// Get initial theme from localStorage or system preference
|
|
14
|
+
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
|
15
|
+
if (stored) {
|
|
16
|
+
setTheme(stored);
|
|
17
|
+
document.documentElement.classList.toggle('dark', stored === 'dark');
|
|
18
|
+
} else {
|
|
19
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
20
|
+
const initialTheme = prefersDark ? 'dark' : 'light';
|
|
21
|
+
setTheme(initialTheme);
|
|
22
|
+
document.documentElement.classList.toggle('dark', prefersDark);
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const toggleTheme = () => {
|
|
27
|
+
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
|
28
|
+
setTheme(newTheme);
|
|
29
|
+
localStorage.setItem('theme', newTheme);
|
|
30
|
+
document.documentElement.classList.toggle('dark', newTheme === 'dark');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
onClick={toggleTheme}
|
|
36
|
+
className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
37
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
38
|
+
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
39
|
+
>
|
|
40
|
+
{theme === 'dark' ? (
|
|
41
|
+
<svg
|
|
42
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
43
|
+
viewBox="0 0 24 24"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
strokeWidth="2"
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
strokeLinejoin="round"
|
|
49
|
+
className={className}
|
|
50
|
+
>
|
|
51
|
+
<circle cx="12" cy="12" r="5" />
|
|
52
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
53
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
54
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
55
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
56
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
57
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
58
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
59
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
60
|
+
</svg>
|
|
61
|
+
) : (
|
|
62
|
+
<svg
|
|
63
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
className={className}
|
|
71
|
+
>
|
|
72
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
73
|
+
</svg>
|
|
74
|
+
)}
|
|
75
|
+
</button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { bounds, layout, type LayoutEdge } from '@/lib/graph/layout';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders the document link graph as an interactive SVG.
|
|
9
|
+
*
|
|
10
|
+
* The layout is computed once from a deterministic seed, so the graph looks the
|
|
11
|
+
* same on every visit — a graph that rearranges itself each time is hard to
|
|
12
|
+
* build any familiarity with. Hovering a node dims everything it is not
|
|
13
|
+
* connected to, which is the only practical way to read a dense graph.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A node as supplied by the server. */
|
|
17
|
+
export interface GraphViewNode {
|
|
18
|
+
path: string;
|
|
19
|
+
title: string;
|
|
20
|
+
url: string;
|
|
21
|
+
degree: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GraphViewProps {
|
|
25
|
+
nodes: GraphViewNode[];
|
|
26
|
+
edges: LayoutEdge[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Nominal layout area; the SVG viewBox scales the result to fit. */
|
|
30
|
+
const AREA = { width: 900, height: 640 };
|
|
31
|
+
|
|
32
|
+
/** Node radius bounds, interpolated by link count. */
|
|
33
|
+
const MIN_RADIUS = 5;
|
|
34
|
+
const MAX_RADIUS = 14;
|
|
35
|
+
|
|
36
|
+
export function GraphView({ nodes, edges }: GraphViewProps) {
|
|
37
|
+
const router = useRouter();
|
|
38
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
const positions = useMemo(() => {
|
|
41
|
+
const settled = layout(
|
|
42
|
+
nodes.map((node) => node.path),
|
|
43
|
+
edges,
|
|
44
|
+
AREA,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return new Map(settled.map((node) => [node.id, node]));
|
|
48
|
+
}, [nodes, edges]);
|
|
49
|
+
|
|
50
|
+
const box = useMemo(() => bounds([...positions.values()]), [positions]);
|
|
51
|
+
|
|
52
|
+
const maxDegree = useMemo(() => Math.max(1, ...nodes.map((node) => node.degree)), [nodes]);
|
|
53
|
+
|
|
54
|
+
/** Paths connected to the hovered node, including itself. */
|
|
55
|
+
const connected = useMemo(() => {
|
|
56
|
+
if (!hovered) return null;
|
|
57
|
+
|
|
58
|
+
const set = new Set<string>([hovered]);
|
|
59
|
+
for (const edge of edges) {
|
|
60
|
+
if (edge.from === hovered) set.add(edge.to);
|
|
61
|
+
if (edge.to === hovered) set.add(edge.from);
|
|
62
|
+
}
|
|
63
|
+
return set;
|
|
64
|
+
}, [hovered, edges]);
|
|
65
|
+
|
|
66
|
+
if (nodes.length === 0) {
|
|
67
|
+
return (
|
|
68
|
+
<p className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
69
|
+
No pages to graph yet.
|
|
70
|
+
</p>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
|
|
76
|
+
<svg
|
|
77
|
+
viewBox={`${box.x} ${box.y} ${box.width} ${box.height}`}
|
|
78
|
+
className="h-[70vh] w-full"
|
|
79
|
+
role="img"
|
|
80
|
+
aria-label={`Link graph of ${nodes.length} pages and ${edges.length} links`}
|
|
81
|
+
>
|
|
82
|
+
<g>
|
|
83
|
+
{edges.map((edge, index) => {
|
|
84
|
+
const from = positions.get(edge.from);
|
|
85
|
+
const to = positions.get(edge.to);
|
|
86
|
+
if (!from || !to) return null;
|
|
87
|
+
|
|
88
|
+
const active = !connected || (connected.has(edge.from) && connected.has(edge.to));
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<line
|
|
92
|
+
key={`${edge.from}->${edge.to}-${index}`}
|
|
93
|
+
x1={from.x}
|
|
94
|
+
y1={from.y}
|
|
95
|
+
x2={to.x}
|
|
96
|
+
y2={to.y}
|
|
97
|
+
className="stroke-gray-300 dark:stroke-gray-700"
|
|
98
|
+
strokeWidth={active ? 1.4 : 0.6}
|
|
99
|
+
opacity={active ? 0.9 : 0.15}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</g>
|
|
104
|
+
|
|
105
|
+
<g>
|
|
106
|
+
{nodes.map((node) => {
|
|
107
|
+
const position = positions.get(node.path);
|
|
108
|
+
if (!position) return null;
|
|
109
|
+
|
|
110
|
+
const radius =
|
|
111
|
+
MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(node.degree / maxDegree);
|
|
112
|
+
const active = !connected || connected.has(node.path);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<g
|
|
116
|
+
key={node.path}
|
|
117
|
+
transform={`translate(${position.x}, ${position.y})`}
|
|
118
|
+
opacity={active ? 1 : 0.2}
|
|
119
|
+
className="cursor-pointer"
|
|
120
|
+
onMouseEnter={() => setHovered(node.path)}
|
|
121
|
+
onMouseLeave={() => setHovered(null)}
|
|
122
|
+
onClick={() => router.push(node.url)}
|
|
123
|
+
onKeyDown={(event) => {
|
|
124
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
router.push(node.url);
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
role="link"
|
|
130
|
+
tabIndex={0}
|
|
131
|
+
aria-label={node.title}
|
|
132
|
+
>
|
|
133
|
+
<circle
|
|
134
|
+
r={radius}
|
|
135
|
+
className={
|
|
136
|
+
hovered === node.path
|
|
137
|
+
? 'fill-blue-500 stroke-white dark:stroke-gray-900'
|
|
138
|
+
: 'fill-blue-400/80 stroke-white dark:fill-blue-500/70 dark:stroke-gray-900'
|
|
139
|
+
}
|
|
140
|
+
strokeWidth={1.5}
|
|
141
|
+
/>
|
|
142
|
+
<text
|
|
143
|
+
y={radius + 12}
|
|
144
|
+
textAnchor="middle"
|
|
145
|
+
className="pointer-events-none fill-gray-700 text-[11px] dark:fill-gray-300"
|
|
146
|
+
>
|
|
147
|
+
{node.title}
|
|
148
|
+
</text>
|
|
149
|
+
</g>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</g>
|
|
153
|
+
</svg>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { CornerUpLeft } from 'lucide-react';
|
|
3
|
+
import type { GraphNode } from '@/lib/graph/build';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lists the pages that link to the current one.
|
|
7
|
+
*
|
|
8
|
+
* Backlinks are what turn a set of documents into a wiki: they show how a page
|
|
9
|
+
* is reached and what context it sits in, which forward links alone never
|
|
10
|
+
* reveal. Computed at build time, so this is a plain server component.
|
|
11
|
+
*/
|
|
12
|
+
export function Backlinks({ links }: { links: GraphNode[] }) {
|
|
13
|
+
if (links.length === 0) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section
|
|
17
|
+
aria-labelledby="backlinks-heading"
|
|
18
|
+
className="mt-16 border-t border-gray-200 pt-6 dark:border-gray-800"
|
|
19
|
+
>
|
|
20
|
+
<h2
|
|
21
|
+
id="backlinks-heading"
|
|
22
|
+
className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
|
23
|
+
>
|
|
24
|
+
<CornerUpLeft className="h-3.5 w-3.5" />
|
|
25
|
+
Linked from {links.length} {links.length === 1 ? 'page' : 'pages'}
|
|
26
|
+
</h2>
|
|
27
|
+
|
|
28
|
+
<ul className="flex flex-wrap gap-2">
|
|
29
|
+
{links.map((link) => (
|
|
30
|
+
<li key={link.path}>
|
|
31
|
+
<Link
|
|
32
|
+
href={link.url}
|
|
33
|
+
className="inline-block rounded-md border border-gray-200 px-2.5 py-1 text-sm text-gray-700 no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
|
|
34
|
+
>
|
|
35
|
+
{link.title}
|
|
36
|
+
</Link>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
</section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { NavigationItem } from '@/lib/payload/types';
|
|
7
|
+
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
8
|
+
|
|
9
|
+
interface BreadcrumbProps {
|
|
10
|
+
navigation: NavigationItem[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build breadcrumb trail by finding the path through navigation tree
|
|
15
|
+
*/
|
|
16
|
+
function buildBreadcrumbTrail(
|
|
17
|
+
items: NavigationItem[],
|
|
18
|
+
targetPath: string,
|
|
19
|
+
trail: Array<{ name: string; path?: string }> = [],
|
|
20
|
+
): Array<{ name: string; path?: string }> | null {
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
if (item.path === targetPath) {
|
|
23
|
+
return [...trail, { name: item.name, path: item.path }];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (item.children) {
|
|
27
|
+
const found = buildBreadcrumbTrail(item.children, targetPath, [
|
|
28
|
+
...trail,
|
|
29
|
+
{ name: item.name, path: item.path },
|
|
30
|
+
]);
|
|
31
|
+
if (found) return found;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Breadcrumb({ navigation }: BreadcrumbProps) {
|
|
39
|
+
const pathname = usePathname();
|
|
40
|
+
const { href: urlFor, toPath } = useUrlMap();
|
|
41
|
+
|
|
42
|
+
// Home page - don't show breadcrumb
|
|
43
|
+
if (pathname === '/') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const currentPath = toPath(pathname);
|
|
48
|
+
|
|
49
|
+
if (!currentPath) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trail = buildBreadcrumbTrail(navigation, currentPath);
|
|
54
|
+
|
|
55
|
+
if (!trail || trail.length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<nav className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
|
61
|
+
{trail.map((item, index) => (
|
|
62
|
+
<React.Fragment key={index}>
|
|
63
|
+
{index > 0 && (
|
|
64
|
+
<svg
|
|
65
|
+
className="w-3.5 h-3.5 flex-shrink-0"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
viewBox="0 0 24 24"
|
|
69
|
+
>
|
|
70
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
71
|
+
</svg>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{index === trail.length - 1 || !item.path ? (
|
|
75
|
+
<span className="font-medium text-gray-900 dark:text-gray-100">{item.name}</span>
|
|
76
|
+
) : (
|
|
77
|
+
<Link
|
|
78
|
+
href={urlFor(item.path)}
|
|
79
|
+
className="hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
80
|
+
>
|
|
81
|
+
{item.name}
|
|
82
|
+
</Link>
|
|
83
|
+
)}
|
|
84
|
+
</React.Fragment>
|
|
85
|
+
))}
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|