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,299 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
6
|
+
import { NavigationItem } from '@/lib/payload/types';
|
|
7
|
+
import { useTabStore } from '@/lib/store/tabStore';
|
|
8
|
+
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
9
|
+
import { filterHiddenItems } from '@/lib/navigation/builder';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for the MobileMenu component
|
|
13
|
+
*/
|
|
14
|
+
interface MobileMenuProps {
|
|
15
|
+
/** Array of top-level navigation items */
|
|
16
|
+
navigation: NavigationItem[];
|
|
17
|
+
/** Whether the mobile menu is currently open */
|
|
18
|
+
isOpen: boolean;
|
|
19
|
+
/** Callback function to close the menu */
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for the MobileNavigationItem component
|
|
25
|
+
*/
|
|
26
|
+
interface MobileNavigationItemProps {
|
|
27
|
+
/** Navigation item to render */
|
|
28
|
+
item: NavigationItem;
|
|
29
|
+
/** Current page path for highlighting active item */
|
|
30
|
+
currentPath: string;
|
|
31
|
+
/** Nesting level for indentation (0 = top level) */
|
|
32
|
+
level: number;
|
|
33
|
+
/** Callback function when navigation occurs */
|
|
34
|
+
onNavigate: () => void;
|
|
35
|
+
/** Background color inherited from parent */
|
|
36
|
+
backgroundColor?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calculate luminance of a color to determine if it's light or dark
|
|
41
|
+
*/
|
|
42
|
+
function isLightColor(hexColor: string): boolean {
|
|
43
|
+
const hex = hexColor.replace('#', '');
|
|
44
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
45
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
46
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
47
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
48
|
+
return luminance > 0.5;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Renders a single navigation item optimized for mobile with touch-friendly
|
|
53
|
+
* targets and automatic menu closing on navigation.
|
|
54
|
+
*
|
|
55
|
+
* @param props - Component props
|
|
56
|
+
*/
|
|
57
|
+
function MobileNavigationItem({
|
|
58
|
+
item,
|
|
59
|
+
currentPath,
|
|
60
|
+
level,
|
|
61
|
+
onNavigate,
|
|
62
|
+
backgroundColor,
|
|
63
|
+
}: MobileNavigationItemProps) {
|
|
64
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
65
|
+
const router = useRouter();
|
|
66
|
+
const { href: urlFor } = useUrlMap();
|
|
67
|
+
const { activeTabId, tabs, addTab } = useTabStore();
|
|
68
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
69
|
+
const isActive = item.path === currentPath;
|
|
70
|
+
|
|
71
|
+
// Calculate left margin based on level using inline styles
|
|
72
|
+
const getLeftMarginStyle = () => {
|
|
73
|
+
if (level === 0) return {};
|
|
74
|
+
const baseMargin = level * 24; // 24px per level
|
|
75
|
+
const extraMargin = hasChildren ? 0 : 4; // Extra 4px for documents
|
|
76
|
+
return { marginLeft: `${baseMargin + extraMargin}px` };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Use item's color if defined, otherwise inherit from parent
|
|
80
|
+
const bgColor = item.color || backgroundColor;
|
|
81
|
+
|
|
82
|
+
// Determine if background is light or dark
|
|
83
|
+
const isLight = bgColor ? isLightColor(bgColor) : true;
|
|
84
|
+
|
|
85
|
+
// Get text colors based on background
|
|
86
|
+
const textColor = isLight ? 'rgb(55, 65, 81)' : 'rgb(243, 244, 246)';
|
|
87
|
+
|
|
88
|
+
// Get background style
|
|
89
|
+
const getBgStyle = (isTopLevel: boolean) => {
|
|
90
|
+
if (!bgColor) return undefined;
|
|
91
|
+
return {
|
|
92
|
+
backgroundColor: bgColor,
|
|
93
|
+
...(isTopLevel && { padding: '4px', borderRadius: '6px' }),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleToggle = () => {
|
|
98
|
+
if (hasChildren) {
|
|
99
|
+
setIsExpanded(!isExpanded);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleLinkClick = (e: React.MouseEvent) => {
|
|
104
|
+
if (item.path) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
const { navigateInHistory } = useTabStore.getState();
|
|
107
|
+
|
|
108
|
+
if (activeTabId) {
|
|
109
|
+
// Add to history and update the active tab's path
|
|
110
|
+
navigateInHistory(activeTabId, item.path, item.name);
|
|
111
|
+
} else if (tabs.length === 0) {
|
|
112
|
+
// No tabs at all, create a new one
|
|
113
|
+
addTab({ title: item.name, path: item.path });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
router.replace(urlFor(item.path));
|
|
117
|
+
onNavigate();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={level === 0 ? 'mb-0.5' : ''} style={level === 0 ? getBgStyle(true) : undefined}>
|
|
123
|
+
<div className="flex items-center" style={level > 0 ? getBgStyle(false) : undefined}>
|
|
124
|
+
{hasChildren ? (
|
|
125
|
+
<button
|
|
126
|
+
onClick={handleToggle}
|
|
127
|
+
className={`flex items-center flex-1 px-2 py-1 rounded-md text-sm transition-colors touch-manipulation ${
|
|
128
|
+
!bgColor
|
|
129
|
+
? 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700'
|
|
130
|
+
: ''
|
|
131
|
+
}`}
|
|
132
|
+
style={bgColor ? { color: textColor, ...getLeftMarginStyle() } : getLeftMarginStyle()}
|
|
133
|
+
// No aria-label: this button already contains the section name, and
|
|
134
|
+
// labelling it "Expand" replaced that name rather than adding to it,
|
|
135
|
+
// leaving every section announced identically. `aria-expanded`
|
|
136
|
+
// carries the state on its own.
|
|
137
|
+
aria-expanded={isExpanded}
|
|
138
|
+
>
|
|
139
|
+
<svg
|
|
140
|
+
className={`w-4 h-4 mr-2 -ml-1 flex-shrink-0 transition-transform duration-[120ms] ${isExpanded ? 'rotate-90' : ''}`}
|
|
141
|
+
style={{ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)' }}
|
|
142
|
+
fill="none"
|
|
143
|
+
stroke="currentColor"
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
>
|
|
146
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
147
|
+
</svg>
|
|
148
|
+
<span className="font-semibold">
|
|
149
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
150
|
+
{item.name}
|
|
151
|
+
</span>
|
|
152
|
+
</button>
|
|
153
|
+
) : item.path ? (
|
|
154
|
+
<Link
|
|
155
|
+
href={urlFor(item.path)}
|
|
156
|
+
onClick={handleLinkClick}
|
|
157
|
+
className={`flex-1 px-2 py-1 rounded-md text-sm transition-colors touch-manipulation ${
|
|
158
|
+
isActive
|
|
159
|
+
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 font-medium'
|
|
160
|
+
: !bgColor
|
|
161
|
+
? 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700'
|
|
162
|
+
: ''
|
|
163
|
+
}`}
|
|
164
|
+
style={
|
|
165
|
+
bgColor && !isActive
|
|
166
|
+
? { color: textColor, ...getLeftMarginStyle() }
|
|
167
|
+
: getLeftMarginStyle()
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
171
|
+
{item.name}
|
|
172
|
+
</Link>
|
|
173
|
+
) : (
|
|
174
|
+
<div
|
|
175
|
+
className={`flex-1 px-2 py-1 text-sm font-semibold ${
|
|
176
|
+
!bgColor ? 'text-gray-900 dark:text-gray-100' : ''
|
|
177
|
+
}`}
|
|
178
|
+
style={bgColor ? { color: textColor, ...getLeftMarginStyle() } : getLeftMarginStyle()}
|
|
179
|
+
>
|
|
180
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
181
|
+
{item.name}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
{hasChildren && (
|
|
186
|
+
<div
|
|
187
|
+
className="overflow-hidden transition-all duration-[120ms]"
|
|
188
|
+
style={{
|
|
189
|
+
maxHeight: isExpanded ? '1000px' : '0',
|
|
190
|
+
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
191
|
+
...getBgStyle(false),
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
{item.children!.map((child, index) => (
|
|
195
|
+
<MobileNavigationItem
|
|
196
|
+
key={`${child.name}-${index}`}
|
|
197
|
+
item={child}
|
|
198
|
+
currentPath={currentPath}
|
|
199
|
+
level={level + 1}
|
|
200
|
+
onNavigate={onNavigate}
|
|
201
|
+
backgroundColor={bgColor}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Displays a full-screen overlay menu for mobile devices. The menu slides
|
|
212
|
+
* in from the left and includes a backdrop overlay. Automatically prevents
|
|
213
|
+
* body scrolling when open and closes when a navigation link is clicked.
|
|
214
|
+
*
|
|
215
|
+
* Visible only on mobile viewports (< 768px). On desktop, the Sidebar
|
|
216
|
+
* component is used instead.
|
|
217
|
+
*
|
|
218
|
+
* @param props - Component props
|
|
219
|
+
* @param props.navigation - Array of top-level navigation items to display
|
|
220
|
+
* @param props.isOpen - Whether the menu is currently open
|
|
221
|
+
* @param props.onClose - Callback function to close the menu
|
|
222
|
+
*
|
|
223
|
+
*/
|
|
224
|
+
export function MobileMenu({ navigation, isOpen, onClose }: MobileMenuProps) {
|
|
225
|
+
const pathname = usePathname();
|
|
226
|
+
const { toPath } = useUrlMap();
|
|
227
|
+
|
|
228
|
+
// Resolve through the URL map rather than trimming the pathname: the URL is
|
|
229
|
+
// a hash under one strategy and carries a trailing slash under the other, and
|
|
230
|
+
// neither form would ever equal a content path.
|
|
231
|
+
const currentPath = toPath(pathname) ?? '';
|
|
232
|
+
|
|
233
|
+
// Filter out hidden items
|
|
234
|
+
const visibleNavigation = filterHiddenItems(navigation);
|
|
235
|
+
|
|
236
|
+
// Prevent body scroll when menu is open
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (isOpen) {
|
|
239
|
+
document.body.style.overflow = 'hidden';
|
|
240
|
+
} else {
|
|
241
|
+
document.body.style.overflow = 'unset';
|
|
242
|
+
}
|
|
243
|
+
return () => {
|
|
244
|
+
document.body.style.overflow = 'unset';
|
|
245
|
+
};
|
|
246
|
+
}, [isOpen]);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<>
|
|
250
|
+
{isOpen && (
|
|
251
|
+
<div
|
|
252
|
+
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden touch-manipulation"
|
|
253
|
+
onClick={onClose}
|
|
254
|
+
onTouchEnd={onClose}
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
role="button"
|
|
257
|
+
tabIndex={-1}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<div
|
|
262
|
+
className={`fixed top-0 left-0 h-full w-80 max-w-[85vw] bg-white dark:bg-gray-900 z-50 transform transition-transform duration-300 ease-in-out md:hidden ${
|
|
263
|
+
isOpen ? 'translate-x-0' : '-translate-x-full'
|
|
264
|
+
} overflow-y-auto`}
|
|
265
|
+
>
|
|
266
|
+
<div className="p-4">
|
|
267
|
+
<nav>
|
|
268
|
+
<div className="flex items-center justify-between mb-1">
|
|
269
|
+
<div className="flex-1" />
|
|
270
|
+
<button
|
|
271
|
+
onClick={onClose}
|
|
272
|
+
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 active:bg-gray-100 dark:active:bg-gray-800 rounded-md transition-colors touch-manipulation"
|
|
273
|
+
aria-label="Close menu"
|
|
274
|
+
>
|
|
275
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
276
|
+
<path
|
|
277
|
+
strokeLinecap="round"
|
|
278
|
+
strokeLinejoin="round"
|
|
279
|
+
strokeWidth={2}
|
|
280
|
+
d="M6 18L18 6M6 6l12 12"
|
|
281
|
+
/>
|
|
282
|
+
</svg>
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
{visibleNavigation.map((item, index) => (
|
|
286
|
+
<MobileNavigationItem
|
|
287
|
+
key={`${item.name}-${index}`}
|
|
288
|
+
item={item}
|
|
289
|
+
currentPath={currentPath}
|
|
290
|
+
level={0}
|
|
291
|
+
onNavigate={onClose}
|
|
292
|
+
/>
|
|
293
|
+
))}
|
|
294
|
+
</nav>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
6
|
+
import { useTabStore } from '@/lib/store/tabStore';
|
|
7
|
+
import { NavigationItem } from '@/lib/payload/types';
|
|
8
|
+
import { Breadcrumb } from './Breadcrumb';
|
|
9
|
+
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
10
|
+
|
|
11
|
+
interface NavigationButtonsProps {
|
|
12
|
+
navigation: NavigationItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Navigation buttons for back/forward history within tabs, combined with breadcrumb
|
|
17
|
+
*/
|
|
18
|
+
export function NavigationButtons({ navigation }: NavigationButtonsProps) {
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const { href: urlFor } = useUrlMap();
|
|
21
|
+
const { activeTabId, goBack, goForward, canGoBack, canGoForward } = useTabStore();
|
|
22
|
+
|
|
23
|
+
const canNavigateBack = activeTabId ? canGoBack(activeTabId) : false;
|
|
24
|
+
const canNavigateForward = activeTabId ? canGoForward(activeTabId) : false;
|
|
25
|
+
|
|
26
|
+
const handleBack = () => {
|
|
27
|
+
if (!activeTabId) return;
|
|
28
|
+
|
|
29
|
+
const result = goBack(activeTabId);
|
|
30
|
+
if (result) {
|
|
31
|
+
router.replace(urlFor(result.path));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleForward = () => {
|
|
36
|
+
if (!activeTabId) return;
|
|
37
|
+
|
|
38
|
+
const result = goForward(activeTabId);
|
|
39
|
+
if (result) {
|
|
40
|
+
router.replace(urlFor(result.path));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-gray-800 h-9 md:h-auto bg-white dark:bg-gray-950">
|
|
46
|
+
<div className="flex items-center gap-1">
|
|
47
|
+
<button
|
|
48
|
+
onClick={handleBack}
|
|
49
|
+
disabled={!canNavigateBack}
|
|
50
|
+
className={`
|
|
51
|
+
p-1 rounded-md transition-colors
|
|
52
|
+
${
|
|
53
|
+
canNavigateBack
|
|
54
|
+
? 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
55
|
+
: 'text-gray-300 dark:text-gray-700 cursor-not-allowed'
|
|
56
|
+
}
|
|
57
|
+
`}
|
|
58
|
+
aria-label="Go back"
|
|
59
|
+
title="Go back"
|
|
60
|
+
>
|
|
61
|
+
<ArrowLeft className="w-4 h-4" />
|
|
62
|
+
</button>
|
|
63
|
+
<button
|
|
64
|
+
onClick={handleForward}
|
|
65
|
+
disabled={!canNavigateForward}
|
|
66
|
+
className={`
|
|
67
|
+
p-1 rounded-md transition-colors
|
|
68
|
+
${
|
|
69
|
+
canNavigateForward
|
|
70
|
+
? 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
71
|
+
: 'text-gray-300 dark:text-gray-700 cursor-not-allowed'
|
|
72
|
+
}
|
|
73
|
+
`}
|
|
74
|
+
aria-label="Go forward"
|
|
75
|
+
title="Go forward"
|
|
76
|
+
>
|
|
77
|
+
<ArrowRight className="w-4 h-4" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Breadcrumb - center */}
|
|
82
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
83
|
+
<Breadcrumb navigation={navigation} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { NavigationItem } from '@/lib/payload/types';
|
|
5
|
+
import { Sidebar } from './Sidebar';
|
|
6
|
+
import { MobileMenu } from './MobileMenu';
|
|
7
|
+
import { TabBar } from './TabBar';
|
|
8
|
+
import { NavigationButtons } from './NavigationButtons';
|
|
9
|
+
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
10
|
+
import { SearchTrigger } from '@/components/search/SearchTrigger';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Props for the PageLayout component
|
|
14
|
+
*/
|
|
15
|
+
interface PageLayoutProps {
|
|
16
|
+
/** Array of top-level navigation items */
|
|
17
|
+
navigation: NavigationItem[];
|
|
18
|
+
/** Page content to render in the main area */
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main page layout wrapper with responsive navigation
|
|
24
|
+
* Manages the mobile menu open/close state internally.
|
|
25
|
+
*
|
|
26
|
+
* @param props - Component props
|
|
27
|
+
* @param props.navigation - Array of navigation items to display in sidebar/menu
|
|
28
|
+
* @param props.children - Page content to render in the main content area
|
|
29
|
+
*
|
|
30
|
+
*/
|
|
31
|
+
export function PageLayout({ navigation, children }: PageLayoutProps) {
|
|
32
|
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
33
|
+
|
|
34
|
+
const toggleMobileMenu = () => {
|
|
35
|
+
setIsMobileMenuOpen(!isMobileMenuOpen);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const closeMobileMenu = () => {
|
|
39
|
+
setIsMobileMenuOpen(false);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="min-h-screen bg-white dark:bg-gray-950">
|
|
44
|
+
<header className="md:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3">
|
|
45
|
+
<div className="flex items-center justify-between">
|
|
46
|
+
<button
|
|
47
|
+
onClick={toggleMobileMenu}
|
|
48
|
+
className="p-2 -ml-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors active:bg-gray-200 dark:active:bg-gray-700 touch-manipulation"
|
|
49
|
+
aria-label="Toggle menu"
|
|
50
|
+
aria-expanded={isMobileMenuOpen}
|
|
51
|
+
>
|
|
52
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
53
|
+
<path
|
|
54
|
+
strokeLinecap="round"
|
|
55
|
+
strokeLinejoin="round"
|
|
56
|
+
strokeWidth={2}
|
|
57
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
58
|
+
/>
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
<SearchTrigger className="mx-3 min-w-0 flex-1" />
|
|
62
|
+
<ThemeToggle />
|
|
63
|
+
</div>
|
|
64
|
+
</header>
|
|
65
|
+
|
|
66
|
+
<MobileMenu navigation={navigation} isOpen={isMobileMenuOpen} onClose={closeMobileMenu} />
|
|
67
|
+
|
|
68
|
+
<div className="flex">
|
|
69
|
+
<Sidebar navigation={navigation} />
|
|
70
|
+
|
|
71
|
+
<main id="main-content" tabIndex={-1} className="flex-1 min-w-0 flex flex-col">
|
|
72
|
+
<div className="sticky top-0 z-20 bg-white dark:bg-gray-950">
|
|
73
|
+
<TabBar />
|
|
74
|
+
<NavigationButtons navigation={navigation} />
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="flex-1 overflow-y-auto">
|
|
78
|
+
{/* Wide enough to seat the table-of-contents rail beside the
|
|
79
|
+
article; the article itself stays measured by its own prose
|
|
80
|
+
width. */}
|
|
81
|
+
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-12">
|
|
82
|
+
{children}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</main>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|