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.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. 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
+ }