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,376 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { ChevronRight, ChevronsLeft, ChevronsRight, Search, Share2 } from 'lucide-react';
|
|
7
|
+
import { NavigationItem } from '@/lib/payload/types';
|
|
8
|
+
import { useTabStore } from '@/lib/store/tabStore';
|
|
9
|
+
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
10
|
+
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
11
|
+
import { SearchTrigger } from '@/components/search/SearchTrigger';
|
|
12
|
+
import { useSearchStore } from '@/lib/store/searchStore';
|
|
13
|
+
import { filterHiddenItems } from '@/lib/navigation/builder';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Props for the Sidebar component
|
|
17
|
+
*/
|
|
18
|
+
interface SidebarProps {
|
|
19
|
+
/** Array of top-level navigation items */
|
|
20
|
+
navigation: NavigationItem[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for the NavigationItemComponent
|
|
25
|
+
*/
|
|
26
|
+
interface NavigationItemComponentProps {
|
|
27
|
+
/** Navigation item to render */
|
|
28
|
+
item: NavigationItem;
|
|
29
|
+
/** Nesting level for indentation (0 = top level) */
|
|
30
|
+
level: number;
|
|
31
|
+
/** Array of booleans indicating which levels should show vertical lines */
|
|
32
|
+
parentLines?: boolean[];
|
|
33
|
+
/** Background color inherited from parent */
|
|
34
|
+
backgroundColor?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calculate luminance of a color to determine if it's light or dark
|
|
39
|
+
* Returns true if the color is light (needs dark text)
|
|
40
|
+
*/
|
|
41
|
+
function isLightColor(hexColor: string): boolean {
|
|
42
|
+
const hex = hexColor.replace('#', '');
|
|
43
|
+
|
|
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
|
+
|
|
48
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
49
|
+
|
|
50
|
+
return luminance > 0.5;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renders a single navigation item with support for nested children.
|
|
55
|
+
* Items with children can be expanded/collapsed. Items with paths are
|
|
56
|
+
* rendered as links and highlighted when active.
|
|
57
|
+
*
|
|
58
|
+
* @param props - Component props
|
|
59
|
+
* @param props.item - Navigation item to render
|
|
60
|
+
* @param props.level - Nesting depth for indentation
|
|
61
|
+
* @param props.isLast - Whether this is the last item in its parent's children
|
|
62
|
+
* @param props.parentLines - Array indicating which levels should show vertical lines
|
|
63
|
+
*/
|
|
64
|
+
function NavigationItemComponent({
|
|
65
|
+
item,
|
|
66
|
+
level,
|
|
67
|
+
parentLines = [],
|
|
68
|
+
backgroundColor,
|
|
69
|
+
}: NavigationItemComponentProps) {
|
|
70
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
71
|
+
const router = useRouter();
|
|
72
|
+
const { href: urlFor } = useUrlMap();
|
|
73
|
+
const { activeTabId, tabs } = useTabStore();
|
|
74
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
75
|
+
|
|
76
|
+
const bgColor = item.color || backgroundColor;
|
|
77
|
+
|
|
78
|
+
const isLight = bgColor ? isLightColor(bgColor) : true;
|
|
79
|
+
|
|
80
|
+
const textColor = isLight ? 'rgb(55, 65, 81)' : 'rgb(243, 244, 246)'; // gray-700 : gray-100
|
|
81
|
+
const lineColor = isLight ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.3)';
|
|
82
|
+
|
|
83
|
+
const getBgStyle = (isTopLevel: boolean) => {
|
|
84
|
+
if (!bgColor) return undefined;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
backgroundColor: bgColor,
|
|
88
|
+
...(isTopLevel && { padding: '4px', paddingBottom: '8px', borderRadius: '6px' }),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
|
93
|
+
const activeTabPath = activeTab?.path || '';
|
|
94
|
+
|
|
95
|
+
const isActive = item.path === activeTabPath;
|
|
96
|
+
|
|
97
|
+
const handleToggle = (e: React.MouseEvent) => {
|
|
98
|
+
if (hasChildren) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
setIsExpanded(!isExpanded);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleLinkClick = (e: React.MouseEvent) => {
|
|
105
|
+
if (item.path) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
const { tabs, addTab: storeAddTab, navigateInHistory } = useTabStore.getState();
|
|
108
|
+
|
|
109
|
+
if (activeTabId) {
|
|
110
|
+
navigateInHistory(activeTabId, item.path, item.name);
|
|
111
|
+
} else if (tabs.length === 0) {
|
|
112
|
+
// No tabs at all, create a new one
|
|
113
|
+
storeAddTab({ title: item.name, path: item.path });
|
|
114
|
+
} else if (tabs.length > 0) {
|
|
115
|
+
// Tabs exist but no active tab, update the first tab
|
|
116
|
+
const firstTab = tabs[0];
|
|
117
|
+
navigateInHistory(firstTab.id, item.path, item.name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
router.replace(urlFor(item.path));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="relative" style={level === 0 ? getBgStyle(true) : undefined}>
|
|
126
|
+
<div className="flex items-center relative" style={level > 0 ? getBgStyle(false) : undefined}>
|
|
127
|
+
{level > 0 && (
|
|
128
|
+
<div className="absolute left-0 top-0 bottom-0 flex">
|
|
129
|
+
{parentLines.map((showLine, idx) => (
|
|
130
|
+
<div key={idx} className="relative" style={{ width: '20px' }}>
|
|
131
|
+
{showLine && (
|
|
132
|
+
<div
|
|
133
|
+
className={`absolute left-1/2 top-0 bottom-0 w-px ${!bgColor ? 'bg-gray-300 dark:bg-gray-700' : ''}`}
|
|
134
|
+
style={{ backgroundColor: bgColor ? lineColor : undefined }}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<div
|
|
143
|
+
className="flex items-center"
|
|
144
|
+
style={{ marginLeft: level > 0 ? `${parentLines.length * 20}px` : '0' }}
|
|
145
|
+
>
|
|
146
|
+
{hasChildren && (
|
|
147
|
+
<button
|
|
148
|
+
onClick={handleToggle}
|
|
149
|
+
className={`mr-1 p-1 rounded transition-colors touch-manipulation flex-shrink-0 ${
|
|
150
|
+
!bgColor
|
|
151
|
+
? 'text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100 hover:bg-black/5 dark:hover:bg-white/10'
|
|
152
|
+
: ''
|
|
153
|
+
}`}
|
|
154
|
+
style={bgColor ? { color: textColor } : undefined}
|
|
155
|
+
// The button is a bare chevron, so it needs a name of its own —
|
|
156
|
+
// and it has to name the section, or a screen reader announces
|
|
157
|
+
// one indistinguishable "Expand" per section.
|
|
158
|
+
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${item.name}`}
|
|
159
|
+
aria-expanded={isExpanded}
|
|
160
|
+
>
|
|
161
|
+
<ChevronRight
|
|
162
|
+
className={`w-4 h-4 transition-transform duration-[120ms] ${isExpanded ? 'rotate-90' : ''}`}
|
|
163
|
+
style={{ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)' }}
|
|
164
|
+
/>
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
{item.path ? (
|
|
168
|
+
<Link
|
|
169
|
+
href={urlFor(item.path)}
|
|
170
|
+
onClick={handleLinkClick}
|
|
171
|
+
className={`flex-1 px-2 py-1 rounded-md text-sm transition-colors touch-manipulation ${
|
|
172
|
+
isActive
|
|
173
|
+
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 font-medium'
|
|
174
|
+
: !bgColor
|
|
175
|
+
? 'text-gray-600 dark:text-gray-300 hover:bg-black/5 dark:hover:bg-white/10 active:bg-black/10 dark:active:bg-white/20'
|
|
176
|
+
: ''
|
|
177
|
+
} ${!hasChildren ? 'ml-5' : ''}`}
|
|
178
|
+
style={bgColor && !isActive ? { color: textColor } : undefined}
|
|
179
|
+
>
|
|
180
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
181
|
+
{item.name}
|
|
182
|
+
</Link>
|
|
183
|
+
) : (
|
|
184
|
+
<div
|
|
185
|
+
className={`flex-1 px-2 py-1 text-sm font-semibold ${
|
|
186
|
+
!bgColor ? 'text-gray-900 dark:text-gray-100' : ''
|
|
187
|
+
} ${!hasChildren ? 'ml-5' : ''}`}
|
|
188
|
+
style={bgColor ? { color: textColor } : undefined}
|
|
189
|
+
>
|
|
190
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
191
|
+
{item.name}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
{hasChildren && (
|
|
197
|
+
<div
|
|
198
|
+
className="overflow-hidden transition-all duration-[120ms]"
|
|
199
|
+
style={{
|
|
200
|
+
maxHeight: isExpanded ? '1000px' : '0',
|
|
201
|
+
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
202
|
+
...getBgStyle(false),
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{item.children!.map((child, index) => {
|
|
206
|
+
const newParentLines = [...parentLines, true];
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<NavigationItemComponent
|
|
210
|
+
key={`${child.name}-${index}`}
|
|
211
|
+
item={child}
|
|
212
|
+
level={level + 1}
|
|
213
|
+
parentLines={newParentLines}
|
|
214
|
+
backgroundColor={bgColor}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Desktop sidebar component displaying hierarchical navigation
|
|
226
|
+
*
|
|
227
|
+
* Renders a fixed sidebar on the left side of the page with the full
|
|
228
|
+
* navigation tree. Supports unlimited nesting with expand/collapse
|
|
229
|
+
* functionality. Automatically highlights the active page.
|
|
230
|
+
*
|
|
231
|
+
* Hidden on mobile viewports (< 768px), where the MobileMenu component
|
|
232
|
+
* is used instead.
|
|
233
|
+
*
|
|
234
|
+
* @param props - Component props
|
|
235
|
+
* @param props.navigation - Array of top-level navigation items to display
|
|
236
|
+
*
|
|
237
|
+
*/
|
|
238
|
+
export function Sidebar({ navigation }: SidebarProps) {
|
|
239
|
+
const { sidebarWidth, sidebarCollapsed, setSidebarWidth, setSidebarCollapsed } = useTabStore();
|
|
240
|
+
|
|
241
|
+
const visibleNavigation = filterHiddenItems(navigation);
|
|
242
|
+
|
|
243
|
+
const openSearch = useSearchStore((state) => state.open);
|
|
244
|
+
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
245
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
246
|
+
|
|
247
|
+
const MIN_WIDTH = 200;
|
|
248
|
+
const MAX_WIDTH = 600;
|
|
249
|
+
const COLLAPSE_THRESHOLD = 150; // Auto-collapse when dragged below this width
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
253
|
+
if (!isResizing) return;
|
|
254
|
+
|
|
255
|
+
const newWidth = e.clientX;
|
|
256
|
+
|
|
257
|
+
if (newWidth < COLLAPSE_THRESHOLD) {
|
|
258
|
+
setSidebarCollapsed(true);
|
|
259
|
+
setIsResizing(false);
|
|
260
|
+
document.body.style.cursor = '';
|
|
261
|
+
document.body.style.userSelect = '';
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
|
266
|
+
setSidebarWidth(newWidth);
|
|
267
|
+
setSidebarCollapsed(false);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const handleMouseUp = () => {
|
|
272
|
+
setIsResizing(false);
|
|
273
|
+
document.body.style.cursor = '';
|
|
274
|
+
document.body.style.userSelect = '';
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (isResizing) {
|
|
278
|
+
document.body.style.cursor = 'col-resize';
|
|
279
|
+
document.body.style.userSelect = 'none';
|
|
280
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
281
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return () => {
|
|
285
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
286
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
287
|
+
document.body.style.cursor = '';
|
|
288
|
+
document.body.style.userSelect = '';
|
|
289
|
+
};
|
|
290
|
+
}, [isResizing, setSidebarWidth, setSidebarCollapsed]);
|
|
291
|
+
|
|
292
|
+
const handleToggle = () => {
|
|
293
|
+
setSidebarCollapsed(!sidebarCollapsed);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const handleResizeStart = (e: React.MouseEvent) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
setIsResizing(true);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<aside
|
|
303
|
+
ref={sidebarRef}
|
|
304
|
+
className="hidden md:block h-screen sticky top-0 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 overflow-y-auto relative"
|
|
305
|
+
style={{
|
|
306
|
+
width: sidebarCollapsed ? '64px' : `${sidebarWidth}px`,
|
|
307
|
+
transition: isResizing ? 'none' : 'width 0.3s ease',
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-200 dark:border-gray-800">
|
|
311
|
+
{!sidebarCollapsed ? (
|
|
312
|
+
<>
|
|
313
|
+
<SearchTrigger className="min-w-0 flex-1" />
|
|
314
|
+
<ThemeToggle className="w-4 h-4" />
|
|
315
|
+
<button
|
|
316
|
+
onClick={handleToggle}
|
|
317
|
+
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md transition-colors flex-shrink-0"
|
|
318
|
+
aria-label="Collapse sidebar"
|
|
319
|
+
title="Collapse sidebar"
|
|
320
|
+
>
|
|
321
|
+
<ChevronsLeft className="w-4 h-4" />
|
|
322
|
+
</button>
|
|
323
|
+
</>
|
|
324
|
+
) : (
|
|
325
|
+
<div className="mx-auto flex flex-col items-center gap-1">
|
|
326
|
+
<button
|
|
327
|
+
onClick={openSearch}
|
|
328
|
+
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
329
|
+
aria-label="Search documentation"
|
|
330
|
+
title="Search documentation"
|
|
331
|
+
>
|
|
332
|
+
<Search className="h-4 w-4" />
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={handleToggle}
|
|
336
|
+
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
337
|
+
aria-label="Expand sidebar"
|
|
338
|
+
title="Expand sidebar"
|
|
339
|
+
>
|
|
340
|
+
<ChevronsRight className="h-4 w-4" />
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{!sidebarCollapsed && (
|
|
347
|
+
<nav className="p-2 space-y-1">
|
|
348
|
+
{visibleNavigation.map((item, index) => (
|
|
349
|
+
<div key={`${item.name}-${index}`} className="rounded-md overflow-hidden">
|
|
350
|
+
<NavigationItemComponent item={item} level={0} parentLines={[]} />
|
|
351
|
+
</div>
|
|
352
|
+
))}
|
|
353
|
+
|
|
354
|
+
<Link
|
|
355
|
+
href="/graph"
|
|
356
|
+
className="mt-2 flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 transition-colors hover:bg-black/5 dark:text-gray-400 dark:hover:bg-white/10"
|
|
357
|
+
>
|
|
358
|
+
<Share2 className="h-4 w-4 flex-shrink-0" />
|
|
359
|
+
Graph
|
|
360
|
+
</Link>
|
|
361
|
+
</nav>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{!sidebarCollapsed && (
|
|
365
|
+
<div
|
|
366
|
+
className="absolute top-0 right-0 w-1 h-full bg-transparent hover:bg-blue-500 transition-colors cursor-col-resize z-50"
|
|
367
|
+
onMouseDown={handleResizeStart}
|
|
368
|
+
style={{
|
|
369
|
+
right: '-2px',
|
|
370
|
+
width: '5px',
|
|
371
|
+
}}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
</aside>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { useTabStore } from '@/lib/store/tabStore';
|
|
6
|
+
import { TabBarSkeleton } from './TabBarSkeleton';
|
|
7
|
+
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
8
|
+
|
|
9
|
+
export function TabBar() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const { href: urlFor } = useUrlMap();
|
|
12
|
+
const {
|
|
13
|
+
tabs,
|
|
14
|
+
activeTabId,
|
|
15
|
+
addTab,
|
|
16
|
+
setActiveTab,
|
|
17
|
+
removeTab,
|
|
18
|
+
closeOtherTabs,
|
|
19
|
+
closeTabsToRight,
|
|
20
|
+
reorderTabs,
|
|
21
|
+
hasHydrated,
|
|
22
|
+
} = useTabStore();
|
|
23
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(
|
|
24
|
+
null,
|
|
25
|
+
);
|
|
26
|
+
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
27
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
28
|
+
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
|
|
29
|
+
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
|
30
|
+
const tabBarRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const handleNewTab = () => {
|
|
33
|
+
addTab({ title: 'New Tab', path: '' });
|
|
34
|
+
router.replace('/');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleTabClick = (tabId: string, path: string) => {
|
|
38
|
+
setActiveTab(tabId);
|
|
39
|
+
|
|
40
|
+
router.replace(path ? urlFor(path) : '/');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const closeTab = (tabId: string) => {
|
|
44
|
+
const wasClosingActiveTab = tabId === activeTabId;
|
|
45
|
+
const remainingTabs = tabs.filter((t) => t.id !== tabId);
|
|
46
|
+
|
|
47
|
+
if (remainingTabs.length === 0) {
|
|
48
|
+
removeTab(tabId);
|
|
49
|
+
addTab({ title: 'New Tab', path: '' });
|
|
50
|
+
|
|
51
|
+
router.replace('/');
|
|
52
|
+
} else {
|
|
53
|
+
removeTab(tabId);
|
|
54
|
+
|
|
55
|
+
if (wasClosingActiveTab && remainingTabs.length > 0) {
|
|
56
|
+
const index = tabs.findIndex((t) => t.id === tabId);
|
|
57
|
+
const newActiveTab = remainingTabs[Math.min(index, remainingTabs.length - 1)];
|
|
58
|
+
|
|
59
|
+
if (newActiveTab) {
|
|
60
|
+
router.replace(newActiveTab.path ? urlFor(newActiveTab.path) : '/');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
closeTab(tabId);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleCloseContextMenu = () => {
|
|
77
|
+
setContextMenu(null);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
81
|
+
setDraggedIndex(index);
|
|
82
|
+
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
83
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
84
|
+
// Create a custom drag image that's invisible
|
|
85
|
+
const img = new Image();
|
|
86
|
+
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
87
|
+
e.dataTransfer.setDragImage(img, 0, 0);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleDrag = (e: React.DragEvent) => {
|
|
91
|
+
if (e.clientX !== 0 && e.clientY !== 0) {
|
|
92
|
+
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
e.dataTransfer.dropEffect = 'move';
|
|
99
|
+
|
|
100
|
+
if (draggedIndex === null || draggedIndex === index) {
|
|
101
|
+
setDragOverIndex(null);
|
|
102
|
+
setDropPosition(null);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
107
|
+
const midPoint = rect.left + rect.width / 2;
|
|
108
|
+
const position = e.clientX < midPoint ? 'before' : 'after';
|
|
109
|
+
|
|
110
|
+
setDragOverIndex(index);
|
|
111
|
+
setDropPosition(position);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleDragLeave = () => {
|
|
115
|
+
setDragOverIndex(null);
|
|
116
|
+
setDropPosition(null);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
|
|
122
|
+
if (draggedIndex === null || draggedIndex === dropIndex) {
|
|
123
|
+
setDraggedIndex(null);
|
|
124
|
+
setDragOverIndex(null);
|
|
125
|
+
setDropPosition(null);
|
|
126
|
+
setDragPosition(null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let targetIndex = dropIndex;
|
|
131
|
+
|
|
132
|
+
if (dropPosition === 'after') {
|
|
133
|
+
targetIndex = dropIndex + 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (draggedIndex < targetIndex) {
|
|
137
|
+
targetIndex -= 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
reorderTabs(draggedIndex, targetIndex);
|
|
141
|
+
setDraggedIndex(null);
|
|
142
|
+
setDragOverIndex(null);
|
|
143
|
+
setDropPosition(null);
|
|
144
|
+
setDragPosition(null);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const handleDragEnd = () => {
|
|
148
|
+
setDraggedIndex(null);
|
|
149
|
+
setDragOverIndex(null);
|
|
150
|
+
setDropPosition(null);
|
|
151
|
+
setDragPosition(null);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (contextMenu) {
|
|
156
|
+
document.addEventListener('click', handleCloseContextMenu);
|
|
157
|
+
return () => document.removeEventListener('click', handleCloseContextMenu);
|
|
158
|
+
}
|
|
159
|
+
}, [contextMenu]);
|
|
160
|
+
|
|
161
|
+
// Show skeleton while hydrating
|
|
162
|
+
if (!hasHydrated) {
|
|
163
|
+
return <TabBarSkeleton />;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
ref={tabBarRef}
|
|
169
|
+
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 overflow-x-auto px-2 py-1 h-9 md:h-auto"
|
|
170
|
+
style={{ scrollbarWidth: 'thin' }}
|
|
171
|
+
>
|
|
172
|
+
{tabs.map((tab, index) => {
|
|
173
|
+
const isActive = tab.id === activeTabId;
|
|
174
|
+
const isDragging = draggedIndex === index;
|
|
175
|
+
const showDropIndicator = dragOverIndex === index && !isDragging;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div key={tab.id} className="relative flex items-center">
|
|
179
|
+
{showDropIndicator && dropPosition === 'before' && (
|
|
180
|
+
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-0.5 bg-blue-500 rounded-full z-10 shadow-lg shadow-blue-500/50" />
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
<div
|
|
184
|
+
draggable
|
|
185
|
+
onDragStart={(e) => handleDragStart(e, index)}
|
|
186
|
+
onDrag={handleDrag}
|
|
187
|
+
onDragOver={(e) => handleDragOver(e, index)}
|
|
188
|
+
onDragLeave={handleDragLeave}
|
|
189
|
+
onDrop={(e) => handleDrop(e, index)}
|
|
190
|
+
onDragEnd={handleDragEnd}
|
|
191
|
+
onClick={() => handleTabClick(tab.id, tab.path)}
|
|
192
|
+
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
|
193
|
+
style={{
|
|
194
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
195
|
+
}}
|
|
196
|
+
className={`
|
|
197
|
+
group flex items-center gap-3 px-3 py-1.5 rounded-md
|
|
198
|
+
transition-all duration-150 w-[180px] flex-shrink-0 relative h-8 md:h-auto
|
|
199
|
+
${
|
|
200
|
+
isActive
|
|
201
|
+
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
|
202
|
+
: 'bg-gray-50 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
203
|
+
}
|
|
204
|
+
${isDragging ? 'opacity-20' : 'opacity-100'}
|
|
205
|
+
`}
|
|
206
|
+
>
|
|
207
|
+
<span className="flex-1 truncate text-sm font-medium min-w-0 pointer-events-none select-none pr-2">
|
|
208
|
+
{tab.title}
|
|
209
|
+
</span>
|
|
210
|
+
<button
|
|
211
|
+
onClick={(e) => handleTabClose(e, tab.id)}
|
|
212
|
+
className={`
|
|
213
|
+
flex-shrink-0 w-5 h-5 min-w-[20px] min-h-[20px] flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-600
|
|
214
|
+
transition-opacity
|
|
215
|
+
${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
|
|
216
|
+
`}
|
|
217
|
+
aria-label="Close tab"
|
|
218
|
+
>
|
|
219
|
+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
220
|
+
<path
|
|
221
|
+
strokeLinecap="round"
|
|
222
|
+
strokeLinejoin="round"
|
|
223
|
+
strokeWidth={2.5}
|
|
224
|
+
d="M6 18L18 6M6 6l12 12"
|
|
225
|
+
/>
|
|
226
|
+
</svg>
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{showDropIndicator && dropPosition === 'after' && (
|
|
231
|
+
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1 w-0.5 bg-blue-500 rounded-full z-10 shadow-lg shadow-blue-500/50" />
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
|
|
237
|
+
{draggedIndex !== null && dragPosition && (
|
|
238
|
+
<div
|
|
239
|
+
className="fixed pointer-events-none z-50"
|
|
240
|
+
style={{
|
|
241
|
+
left: dragPosition.x,
|
|
242
|
+
top: dragPosition.y,
|
|
243
|
+
transform: 'translate(-50%, -50%)',
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<div
|
|
247
|
+
className={`
|
|
248
|
+
flex items-center gap-2 px-3 py-1.5 rounded-md w-[180px]
|
|
249
|
+
shadow-2xl scale-105 opacity-80
|
|
250
|
+
${
|
|
251
|
+
tabs[draggedIndex]?.id === activeTabId
|
|
252
|
+
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
|
253
|
+
: 'bg-gray-50 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400'
|
|
254
|
+
}
|
|
255
|
+
`}
|
|
256
|
+
>
|
|
257
|
+
<span className="flex-1 truncate text-sm font-medium min-w-0 select-none">
|
|
258
|
+
{tabs[draggedIndex]?.title}
|
|
259
|
+
</span>
|
|
260
|
+
<div className="flex-shrink-0 w-4 h-4" />
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
<button
|
|
266
|
+
onClick={handleNewTab}
|
|
267
|
+
className="flex-shrink-0 p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
|
268
|
+
aria-label="New tab"
|
|
269
|
+
title="New tab"
|
|
270
|
+
>
|
|
271
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
272
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
273
|
+
</svg>
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
{contextMenu && (
|
|
277
|
+
<div
|
|
278
|
+
className="fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
|
279
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
280
|
+
>
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => {
|
|
283
|
+
closeTab(contextMenu.tabId);
|
|
284
|
+
handleCloseContextMenu();
|
|
285
|
+
}}
|
|
286
|
+
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
287
|
+
>
|
|
288
|
+
Close
|
|
289
|
+
</button>
|
|
290
|
+
<button
|
|
291
|
+
onClick={() => {
|
|
292
|
+
closeOtherTabs(contextMenu.tabId);
|
|
293
|
+
handleCloseContextMenu();
|
|
294
|
+
}}
|
|
295
|
+
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
296
|
+
>
|
|
297
|
+
Close Others
|
|
298
|
+
</button>
|
|
299
|
+
<button
|
|
300
|
+
onClick={() => {
|
|
301
|
+
closeTabsToRight(contextMenu.tabId);
|
|
302
|
+
handleCloseContextMenu();
|
|
303
|
+
}}
|
|
304
|
+
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
305
|
+
>
|
|
306
|
+
Close to the Right
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function TabBarSkeleton() {
|
|
2
|
+
return (
|
|
3
|
+
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 overflow-x-auto px-2 py-1">
|
|
4
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md w-[180px] flex-shrink-0 bg-gray-50 dark:bg-gray-800/50 animate-pulse">
|
|
5
|
+
<div className="flex-1 h-4 bg-gray-300 dark:bg-gray-700 rounded" />
|
|
6
|
+
<div className="w-3.5 h-3.5 bg-gray-300 dark:bg-gray-700 rounded" />
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div className="flex-shrink-0 p-2 w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse" />
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|