prev-cli 0.7.2 → 0.9.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/dist/cli.js +48 -24
- package/dist/vite/config.d.ts +1 -0
- package/dist/vite/pages.d.ts +4 -1
- package/dist/vite/plugins/pages-plugin.d.ts +4 -1
- package/dist/vite/start.d.ts +5 -1
- package/package.json +1 -1
- package/src/theme/Layout.tsx +317 -46
- package/src/theme/MetadataBlock.tsx +17 -28
- package/src/theme/entry.tsx +1 -5
- package/src/theme/styles.css +54 -19
package/dist/cli.js
CHANGED
|
@@ -136,14 +136,30 @@ function fileToRoute(file) {
|
|
|
136
136
|
}
|
|
137
137
|
return "/" + withoutExt;
|
|
138
138
|
}
|
|
139
|
-
async function scanPages(rootDir) {
|
|
140
|
-
const
|
|
139
|
+
async function scanPages(rootDir, options = {}) {
|
|
140
|
+
const { include = [] } = options;
|
|
141
|
+
const includeDirs = include.map((dir) => dir.startsWith(".") ? dir : `.${dir}`);
|
|
142
|
+
const patterns = ["**/*.{md,mdx}"];
|
|
143
|
+
for (const dir of includeDirs) {
|
|
144
|
+
patterns.push(`${dir}/**/*.{md,mdx}`);
|
|
145
|
+
}
|
|
146
|
+
const ignore = ["node_modules/**", "dist/**", ".cache/**"];
|
|
147
|
+
const files = await fg.glob(patterns, {
|
|
141
148
|
cwd: rootDir,
|
|
142
|
-
ignore
|
|
149
|
+
ignore,
|
|
143
150
|
dot: true
|
|
144
151
|
});
|
|
152
|
+
const filteredFiles = files.filter((file) => {
|
|
153
|
+
const parts = file.split("/");
|
|
154
|
+
for (const part of parts) {
|
|
155
|
+
if (part.startsWith(".") && part !== ".") {
|
|
156
|
+
return includeDirs.some((dir) => file.startsWith(dir.slice(1)) || file.startsWith(dir));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
145
161
|
const routeMap = new Map;
|
|
146
|
-
for (const file of
|
|
162
|
+
for (const file of filteredFiles) {
|
|
147
163
|
const route = fileToRoute(file);
|
|
148
164
|
const basename = path2.basename(file, path2.extname(file)).toLowerCase();
|
|
149
165
|
const priority = basename === "index" ? 1 : basename === "readme" ? 2 : 0;
|
|
@@ -222,7 +238,8 @@ function buildSidebarTree(pages) {
|
|
|
222
238
|
// src/vite/plugins/pages-plugin.ts
|
|
223
239
|
var VIRTUAL_MODULE_ID = "virtual:prev-pages";
|
|
224
240
|
var RESOLVED_VIRTUAL_MODULE_ID = "\x00" + VIRTUAL_MODULE_ID;
|
|
225
|
-
function pagesPlugin(rootDir) {
|
|
241
|
+
function pagesPlugin(rootDir, options = {}) {
|
|
242
|
+
const { include } = options;
|
|
226
243
|
return {
|
|
227
244
|
name: "prev-pages",
|
|
228
245
|
resolveId(id) {
|
|
@@ -232,7 +249,7 @@ function pagesPlugin(rootDir) {
|
|
|
232
249
|
},
|
|
233
250
|
async load(id) {
|
|
234
251
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
235
|
-
const pages = await scanPages(rootDir);
|
|
252
|
+
const pages = await scanPages(rootDir, { include });
|
|
236
253
|
const sidebar = buildSidebarTree(pages);
|
|
237
254
|
return `
|
|
238
255
|
export const pages = ${JSON.stringify(pages)};
|
|
@@ -439,7 +456,7 @@ var cliRoot2 = findCliRoot2();
|
|
|
439
456
|
var cliNodeModules = findNodeModules(cliRoot2);
|
|
440
457
|
var srcRoot2 = path4.join(cliRoot2, "src");
|
|
441
458
|
async function createViteConfig(options) {
|
|
442
|
-
const { rootDir, mode, port } = options;
|
|
459
|
+
const { rootDir, mode, port, include } = options;
|
|
443
460
|
const cacheDir = await ensureCacheDir(rootDir);
|
|
444
461
|
return {
|
|
445
462
|
root: rootDir,
|
|
@@ -453,7 +470,7 @@ async function createViteConfig(options) {
|
|
|
453
470
|
rehypePlugins: [rehypeHighlight]
|
|
454
471
|
}),
|
|
455
472
|
react(),
|
|
456
|
-
pagesPlugin(rootDir),
|
|
473
|
+
pagesPlugin(rootDir, { include }),
|
|
457
474
|
entryPlugin(rootDir)
|
|
458
475
|
],
|
|
459
476
|
resolve: {
|
|
@@ -568,7 +585,8 @@ async function startDev(rootDir, options = {}) {
|
|
|
568
585
|
const config = await createViteConfig({
|
|
569
586
|
rootDir,
|
|
570
587
|
mode: "development",
|
|
571
|
-
port
|
|
588
|
+
port,
|
|
589
|
+
include: options.include
|
|
572
590
|
});
|
|
573
591
|
const server = await createServer2(config);
|
|
574
592
|
await server.listen();
|
|
@@ -577,14 +595,15 @@ async function startDev(rootDir, options = {}) {
|
|
|
577
595
|
printReady();
|
|
578
596
|
return server;
|
|
579
597
|
}
|
|
580
|
-
async function buildSite(rootDir) {
|
|
598
|
+
async function buildSite(rootDir, options = {}) {
|
|
581
599
|
console.log();
|
|
582
600
|
console.log(" ✨ prev build");
|
|
583
601
|
console.log();
|
|
584
602
|
console.log(" Building your documentation site...");
|
|
585
603
|
const config = await createViteConfig({
|
|
586
604
|
rootDir,
|
|
587
|
-
mode: "production"
|
|
605
|
+
mode: "production",
|
|
606
|
+
include: options.include
|
|
588
607
|
});
|
|
589
608
|
await build(config);
|
|
590
609
|
console.log();
|
|
@@ -597,7 +616,8 @@ async function previewSite(rootDir, options = {}) {
|
|
|
597
616
|
const config = await createViteConfig({
|
|
598
617
|
rootDir,
|
|
599
618
|
mode: "production",
|
|
600
|
-
port
|
|
619
|
+
port,
|
|
620
|
+
include: options.include
|
|
601
621
|
});
|
|
602
622
|
const server = await preview(config);
|
|
603
623
|
printWelcome("preview");
|
|
@@ -615,6 +635,7 @@ var { values, positionals } = parseArgs({
|
|
|
615
635
|
port: { type: "string", short: "p" },
|
|
616
636
|
days: { type: "string", short: "d" },
|
|
617
637
|
cwd: { type: "string", short: "c" },
|
|
638
|
+
include: { type: "string", short: "i", multiple: true },
|
|
618
639
|
help: { type: "boolean", short: "h" }
|
|
619
640
|
},
|
|
620
641
|
allowPositionals: true
|
|
@@ -635,17 +656,19 @@ Commands:
|
|
|
635
656
|
clean Remove old cache directories
|
|
636
657
|
|
|
637
658
|
Options:
|
|
638
|
-
-c, --cwd <path>
|
|
639
|
-
-p, --port <port>
|
|
640
|
-
-
|
|
641
|
-
-
|
|
659
|
+
-c, --cwd <path> Set working directory
|
|
660
|
+
-p, --port <port> Specify port (dev/preview)
|
|
661
|
+
-i, --include <dir> Include dot-prefixed directory (can use multiple times)
|
|
662
|
+
-d, --days <days> Cache age threshold for clean (default: 30)
|
|
663
|
+
-h, --help Show this help message
|
|
642
664
|
|
|
643
665
|
Examples:
|
|
644
|
-
prev
|
|
645
|
-
prev dev -p 3000
|
|
646
|
-
prev build
|
|
647
|
-
prev
|
|
648
|
-
prev
|
|
666
|
+
prev Start dev server on random port
|
|
667
|
+
prev dev -p 3000 Start dev server on port 3000
|
|
668
|
+
prev build Build static site to ./dist
|
|
669
|
+
prev dev -i .c3 Include .c3 directory in docs
|
|
670
|
+
prev dev -i .c3 -i .notes Include multiple dot directories
|
|
671
|
+
prev clean -d 7 Remove caches older than 7 days
|
|
649
672
|
`);
|
|
650
673
|
}
|
|
651
674
|
async function main() {
|
|
@@ -655,16 +678,17 @@ async function main() {
|
|
|
655
678
|
}
|
|
656
679
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
657
680
|
const days = values.days ? parseInt(values.days, 10) : 30;
|
|
681
|
+
const include = values.include || [];
|
|
658
682
|
try {
|
|
659
683
|
switch (command) {
|
|
660
684
|
case "dev":
|
|
661
|
-
await startDev(rootDir, { port });
|
|
685
|
+
await startDev(rootDir, { port, include });
|
|
662
686
|
break;
|
|
663
687
|
case "build":
|
|
664
|
-
await buildSite(rootDir);
|
|
688
|
+
await buildSite(rootDir, { include });
|
|
665
689
|
break;
|
|
666
690
|
case "preview":
|
|
667
|
-
await previewSite(rootDir, { port });
|
|
691
|
+
await previewSite(rootDir, { port, include });
|
|
668
692
|
break;
|
|
669
693
|
case "clean":
|
|
670
694
|
const removed = await cleanCache({ maxAgeDays: days });
|
package/dist/vite/config.d.ts
CHANGED
package/dist/vite/pages.d.ts
CHANGED
|
@@ -23,5 +23,8 @@ export declare function parseFrontmatter(content: string): {
|
|
|
23
23
|
content: string;
|
|
24
24
|
};
|
|
25
25
|
export declare function fileToRoute(file: string): string;
|
|
26
|
-
export
|
|
26
|
+
export interface ScanOptions {
|
|
27
|
+
include?: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function scanPages(rootDir: string, options?: ScanOptions): Promise<Page[]>;
|
|
27
30
|
export declare function buildSidebarTree(pages: Page[]): SidebarItem[];
|
package/dist/vite/start.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export interface DevOptions {
|
|
2
2
|
port?: number;
|
|
3
|
+
include?: string[];
|
|
4
|
+
}
|
|
5
|
+
export interface BuildOptions {
|
|
6
|
+
include?: string[];
|
|
3
7
|
}
|
|
4
8
|
export declare function startDev(rootDir: string, options?: DevOptions): Promise<import("vite").ViteDevServer>;
|
|
5
|
-
export declare function buildSite(rootDir: string): Promise<void>;
|
|
9
|
+
export declare function buildSite(rootDir: string, options?: BuildOptions): Promise<void>;
|
|
6
10
|
export declare function previewSite(rootDir: string, options?: DevOptions): Promise<import("vite").PreviewServer>;
|
package/package.json
CHANGED
package/src/theme/Layout.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
1
|
+
import React, { useState, useEffect, useRef, createContext, useContext } from 'react'
|
|
2
2
|
import { Link, useLocation } from '@tanstack/react-router'
|
|
3
3
|
import type { PageTree } from 'fumadocs-core/server'
|
|
4
4
|
|
|
@@ -7,6 +7,77 @@ interface LayoutProps {
|
|
|
7
7
|
children: React.ReactNode
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
type TreeItem = PageTree.Item | PageTree.Folder
|
|
11
|
+
|
|
12
|
+
// Get unique ID for a tree item
|
|
13
|
+
function getItemId(item: TreeItem): string {
|
|
14
|
+
return item.type === 'folder' ? `folder:${item.name}` : item.url
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Storage key for a specific path (root or folder)
|
|
18
|
+
function getStorageKey(path: string): string {
|
|
19
|
+
return `sidebar-order:${path}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Apply saved order to items for a specific path
|
|
23
|
+
// Handles: new items (appended), removed items (skipped), stale storage (cleaned)
|
|
24
|
+
function applyOrder(items: TreeItem[], path: string): TreeItem[] {
|
|
25
|
+
if (typeof window === 'undefined') return items
|
|
26
|
+
|
|
27
|
+
const saved = localStorage.getItem(getStorageKey(path))
|
|
28
|
+
if (!saved) return items
|
|
29
|
+
|
|
30
|
+
const order: string[] = JSON.parse(saved)
|
|
31
|
+
if (order.length === 0) return items
|
|
32
|
+
|
|
33
|
+
const itemMap = new Map(items.map(item => [getItemId(item), item]))
|
|
34
|
+
const ordered: TreeItem[] = []
|
|
35
|
+
|
|
36
|
+
// Add items in saved order (skip removed ones)
|
|
37
|
+
for (const id of order) {
|
|
38
|
+
const item = itemMap.get(id)
|
|
39
|
+
if (item) {
|
|
40
|
+
ordered.push(item)
|
|
41
|
+
itemMap.delete(id)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add remaining items (new ones not in saved order)
|
|
46
|
+
for (const item of itemMap.values()) {
|
|
47
|
+
ordered.push(item)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Clean up stale entries: if order changed, update storage
|
|
51
|
+
const currentIds = ordered.map(getItemId)
|
|
52
|
+
const hasChanges = order.length !== currentIds.length ||
|
|
53
|
+
order.some((id, i) => currentIds[i] !== id)
|
|
54
|
+
|
|
55
|
+
if (hasChanges) {
|
|
56
|
+
localStorage.setItem(getStorageKey(path), JSON.stringify(currentIds))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ordered
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Save order for a specific path
|
|
63
|
+
function saveOrder(items: TreeItem[], path: string): void {
|
|
64
|
+
const order = items.map(getItemId)
|
|
65
|
+
localStorage.setItem(getStorageKey(path), JSON.stringify(order))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Drag context for nested components
|
|
69
|
+
interface DragContextType {
|
|
70
|
+
dragPath: string | null
|
|
71
|
+
dragIndex: number | null
|
|
72
|
+
dragOverPath: string | null
|
|
73
|
+
dragOverIndex: number | null
|
|
74
|
+
onDragStart: (path: string, index: number) => void
|
|
75
|
+
onDragOver: (path: string, index: number) => void
|
|
76
|
+
onDragEnd: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DragContext = createContext<DragContextType | null>(null)
|
|
80
|
+
|
|
10
81
|
function SidebarToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
|
11
82
|
return (
|
|
12
83
|
<button
|
|
@@ -41,23 +112,55 @@ function CollapsedFolderIcon({ item, onClick }: { item: PageTree.Folder; onClick
|
|
|
41
112
|
)
|
|
42
113
|
}
|
|
43
114
|
|
|
44
|
-
interface
|
|
45
|
-
item:
|
|
115
|
+
interface DraggableSidebarItemProps {
|
|
116
|
+
item: TreeItem
|
|
117
|
+
index: number
|
|
118
|
+
path: string
|
|
46
119
|
depth?: number
|
|
47
120
|
}
|
|
48
121
|
|
|
49
|
-
function
|
|
122
|
+
function DraggableSidebarItem({ item, index, path, depth = 0 }: DraggableSidebarItemProps) {
|
|
50
123
|
const location = useLocation()
|
|
51
124
|
const [isOpen, setIsOpen] = useState(true)
|
|
125
|
+
const dragCtx = useContext(DragContext)
|
|
126
|
+
|
|
127
|
+
const handleDragStart = (e: React.DragEvent) => {
|
|
128
|
+
e.stopPropagation()
|
|
129
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
130
|
+
dragCtx?.onDragStart(path, index)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
e.dataTransfer.dropEffect = 'move'
|
|
137
|
+
dragCtx?.onDragOver(path, index)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handleDragEnd = (e: React.DragEvent) => {
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
dragCtx?.onDragEnd()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isDropTarget = dragCtx?.dragOverPath === path && dragCtx?.dragOverIndex === index
|
|
52
146
|
|
|
53
147
|
if (item.type === 'folder') {
|
|
148
|
+
const folderPath = `${path}/${item.name}`
|
|
149
|
+
|
|
54
150
|
return (
|
|
55
|
-
<div
|
|
151
|
+
<div
|
|
152
|
+
className={`sidebar-folder ${isDropTarget ? 'drop-target' : ''}`}
|
|
153
|
+
draggable
|
|
154
|
+
onDragStart={handleDragStart}
|
|
155
|
+
onDragOver={handleDragOver}
|
|
156
|
+
onDragEnd={handleDragEnd}
|
|
157
|
+
>
|
|
56
158
|
<button
|
|
57
159
|
className="sidebar-folder-toggle"
|
|
58
160
|
onClick={() => setIsOpen(!isOpen)}
|
|
59
161
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
60
162
|
>
|
|
163
|
+
<span className="drag-handle">⠿</span>
|
|
61
164
|
<span className={`folder-icon ${isOpen ? 'open' : ''}`}>
|
|
62
165
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
63
166
|
<path d="M4.5 2L8.5 6L4.5 10" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
|
@@ -66,11 +169,11 @@ function SidebarItem({ item, depth = 0 }: SidebarItemProps) {
|
|
|
66
169
|
{item.name}
|
|
67
170
|
</button>
|
|
68
171
|
{isOpen && (
|
|
69
|
-
<
|
|
70
|
-
{item.children
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
172
|
+
<DraggableFolderChildren
|
|
173
|
+
items={item.children}
|
|
174
|
+
path={folderPath}
|
|
175
|
+
depth={depth + 1}
|
|
176
|
+
/>
|
|
74
177
|
)}
|
|
75
178
|
</div>
|
|
76
179
|
)
|
|
@@ -80,13 +183,81 @@ function SidebarItem({ item, depth = 0 }: SidebarItemProps) {
|
|
|
80
183
|
(item.url === '/' && location.pathname === '/')
|
|
81
184
|
|
|
82
185
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
186
|
+
<div
|
|
187
|
+
className={`sidebar-item-wrapper ${isDropTarget ? 'drop-target' : ''}`}
|
|
188
|
+
draggable
|
|
189
|
+
onDragStart={handleDragStart}
|
|
190
|
+
onDragOver={handleDragOver}
|
|
191
|
+
onDragEnd={handleDragEnd}
|
|
87
192
|
>
|
|
88
|
-
|
|
89
|
-
|
|
193
|
+
<Link
|
|
194
|
+
to={item.url}
|
|
195
|
+
className={`sidebar-link ${isActive ? 'active' : ''}`}
|
|
196
|
+
style={{ paddingLeft: `${depth * 12 + 16}px` }}
|
|
197
|
+
>
|
|
198
|
+
<span className="drag-handle">⠿</span>
|
|
199
|
+
{item.name}
|
|
200
|
+
</Link>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Draggable folder children with their own order state
|
|
206
|
+
function DraggableFolderChildren({ items, path, depth }: { items: TreeItem[]; path: string; depth: number }) {
|
|
207
|
+
const [orderedItems, setOrderedItems] = useState<TreeItem[]>(() => applyOrder(items, path))
|
|
208
|
+
const dragCtx = useContext(DragContext)
|
|
209
|
+
|
|
210
|
+
// Update when items change
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
setOrderedItems(applyOrder(items, path))
|
|
213
|
+
}, [items, path])
|
|
214
|
+
|
|
215
|
+
// Handle reorder when drag ends in this folder
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (
|
|
218
|
+
dragCtx?.dragPath === path &&
|
|
219
|
+
dragCtx?.dragOverPath === path &&
|
|
220
|
+
dragCtx?.dragIndex !== null &&
|
|
221
|
+
dragCtx?.dragOverIndex !== null &&
|
|
222
|
+
dragCtx?.dragIndex !== dragCtx?.dragOverIndex
|
|
223
|
+
) {
|
|
224
|
+
// Will be handled by parent's onDragEnd
|
|
225
|
+
}
|
|
226
|
+
}, [dragCtx, path])
|
|
227
|
+
|
|
228
|
+
// Listen for successful drops in this path
|
|
229
|
+
const prevDragCtx = useRef(dragCtx)
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const prev = prevDragCtx.current
|
|
232
|
+
if (
|
|
233
|
+
prev?.dragPath === path &&
|
|
234
|
+
prev?.dragOverPath === path &&
|
|
235
|
+
prev?.dragIndex !== null &&
|
|
236
|
+
prev?.dragOverIndex !== null &&
|
|
237
|
+
prev?.dragIndex !== prev?.dragOverIndex &&
|
|
238
|
+
dragCtx?.dragPath === null // drag ended
|
|
239
|
+
) {
|
|
240
|
+
const newItems = [...orderedItems]
|
|
241
|
+
const [removed] = newItems.splice(prev.dragIndex, 1)
|
|
242
|
+
newItems.splice(prev.dragOverIndex, 0, removed)
|
|
243
|
+
setOrderedItems(newItems)
|
|
244
|
+
saveOrder(newItems, path)
|
|
245
|
+
}
|
|
246
|
+
prevDragCtx.current = dragCtx
|
|
247
|
+
}, [dragCtx, path, orderedItems])
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div className="sidebar-folder-children">
|
|
251
|
+
{orderedItems.map((child, i) => (
|
|
252
|
+
<DraggableSidebarItem
|
|
253
|
+
key={getItemId(child)}
|
|
254
|
+
item={child}
|
|
255
|
+
index={i}
|
|
256
|
+
path={path}
|
|
257
|
+
depth={depth}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
90
261
|
)
|
|
91
262
|
}
|
|
92
263
|
|
|
@@ -121,6 +292,41 @@ function ThemeToggle() {
|
|
|
121
292
|
)
|
|
122
293
|
}
|
|
123
294
|
|
|
295
|
+
function WidthToggle() {
|
|
296
|
+
const [isFullWidth, setIsFullWidth] = useState(() => {
|
|
297
|
+
if (typeof window !== 'undefined') {
|
|
298
|
+
return localStorage.getItem('content-full-width') === 'true'
|
|
299
|
+
}
|
|
300
|
+
return false
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
document.documentElement.classList.toggle('full-width', isFullWidth)
|
|
305
|
+
}, [isFullWidth])
|
|
306
|
+
|
|
307
|
+
const toggle = () => {
|
|
308
|
+
const newFullWidth = !isFullWidth
|
|
309
|
+
setIsFullWidth(newFullWidth)
|
|
310
|
+
localStorage.setItem('content-full-width', String(newFullWidth))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<button className="theme-toggle" onClick={toggle} aria-label="Toggle content width">
|
|
315
|
+
{isFullWidth ? (
|
|
316
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
317
|
+
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>
|
|
318
|
+
</svg>
|
|
319
|
+
) : (
|
|
320
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
321
|
+
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
|
322
|
+
</svg>
|
|
323
|
+
)}
|
|
324
|
+
</button>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ROOT_PATH = 'root'
|
|
329
|
+
|
|
124
330
|
export function Layout({ tree, children }: LayoutProps) {
|
|
125
331
|
const [collapsed, setCollapsed] = useState(() => {
|
|
126
332
|
if (typeof window !== 'undefined') {
|
|
@@ -129,6 +335,13 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
129
335
|
return false
|
|
130
336
|
})
|
|
131
337
|
|
|
338
|
+
const [orderedItems, setOrderedItems] = useState<TreeItem[]>(() => applyOrder(tree.children, ROOT_PATH))
|
|
339
|
+
|
|
340
|
+
const [dragPath, setDragPath] = useState<string | null>(null)
|
|
341
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
342
|
+
const [dragOverPath, setDragOverPath] = useState<string | null>(null)
|
|
343
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
344
|
+
|
|
132
345
|
// Initialize theme from localStorage
|
|
133
346
|
useEffect(() => {
|
|
134
347
|
const saved = localStorage.getItem('theme')
|
|
@@ -137,6 +350,11 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
137
350
|
document.documentElement.classList.toggle('dark', isDark)
|
|
138
351
|
}, [])
|
|
139
352
|
|
|
353
|
+
// Update ordered items when tree changes
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
setOrderedItems(applyOrder(tree.children, ROOT_PATH))
|
|
356
|
+
}, [tree.children])
|
|
357
|
+
|
|
140
358
|
// Persist collapsed state
|
|
141
359
|
useEffect(() => {
|
|
142
360
|
localStorage.setItem('sidebar-collapsed', String(collapsed))
|
|
@@ -144,43 +362,96 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
144
362
|
|
|
145
363
|
const toggleCollapsed = () => setCollapsed(!collapsed)
|
|
146
364
|
|
|
365
|
+
const handleDragStart = (path: string, index: number) => {
|
|
366
|
+
setDragPath(path)
|
|
367
|
+
setDragIndex(index)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const handleDragOver = (path: string, index: number) => {
|
|
371
|
+
// Only allow dropping in the same path (no cross-folder drops)
|
|
372
|
+
if (dragPath === path && dragIndex !== index) {
|
|
373
|
+
setDragOverPath(path)
|
|
374
|
+
setDragOverIndex(index)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const handleDragEnd = () => {
|
|
379
|
+
// Handle root-level reordering
|
|
380
|
+
if (
|
|
381
|
+
dragPath === ROOT_PATH &&
|
|
382
|
+
dragOverPath === ROOT_PATH &&
|
|
383
|
+
dragIndex !== null &&
|
|
384
|
+
dragOverIndex !== null &&
|
|
385
|
+
dragIndex !== dragOverIndex
|
|
386
|
+
) {
|
|
387
|
+
const newItems = [...orderedItems]
|
|
388
|
+
const [removed] = newItems.splice(dragIndex, 1)
|
|
389
|
+
newItems.splice(dragOverIndex, 0, removed)
|
|
390
|
+
setOrderedItems(newItems)
|
|
391
|
+
saveOrder(newItems, ROOT_PATH)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setDragPath(null)
|
|
395
|
+
setDragIndex(null)
|
|
396
|
+
setDragOverPath(null)
|
|
397
|
+
setDragOverIndex(null)
|
|
398
|
+
}
|
|
399
|
+
|
|
147
400
|
// Get top-level folders for collapsed rail
|
|
148
|
-
const topFolders =
|
|
401
|
+
const topFolders = orderedItems.filter(
|
|
149
402
|
(item): item is PageTree.Folder => item.type === 'folder'
|
|
150
403
|
)
|
|
151
404
|
|
|
405
|
+
const dragContextValue: DragContextType = {
|
|
406
|
+
dragPath,
|
|
407
|
+
dragIndex,
|
|
408
|
+
dragOverPath,
|
|
409
|
+
dragOverIndex,
|
|
410
|
+
onDragStart: handleDragStart,
|
|
411
|
+
onDragOver: handleDragOver,
|
|
412
|
+
onDragEnd: handleDragEnd,
|
|
413
|
+
}
|
|
414
|
+
|
|
152
415
|
return (
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
<
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
{collapsed ? (
|
|
159
|
-
<div className="sidebar-rail">
|
|
160
|
-
{topFolders.map((folder, i) => (
|
|
161
|
-
<CollapsedFolderIcon
|
|
162
|
-
key={i}
|
|
163
|
-
item={folder}
|
|
164
|
-
onClick={toggleCollapsed}
|
|
165
|
-
/>
|
|
166
|
-
))}
|
|
416
|
+
<DragContext.Provider value={dragContextValue}>
|
|
417
|
+
<div className={`prev-layout ${collapsed ? 'sidebar-collapsed' : ''}`}>
|
|
418
|
+
<aside className="prev-sidebar">
|
|
419
|
+
<div className="sidebar-header">
|
|
420
|
+
<SidebarToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
|
167
421
|
</div>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
422
|
+
{collapsed ? (
|
|
423
|
+
<div className="sidebar-rail">
|
|
424
|
+
{topFolders.map((folder, i) => (
|
|
425
|
+
<CollapsedFolderIcon
|
|
426
|
+
key={i}
|
|
427
|
+
item={folder}
|
|
428
|
+
onClick={toggleCollapsed}
|
|
429
|
+
/>
|
|
173
430
|
))}
|
|
174
|
-
</nav>
|
|
175
|
-
<div className="sidebar-footer">
|
|
176
|
-
<ThemeToggle />
|
|
177
431
|
</div>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
432
|
+
) : (
|
|
433
|
+
<>
|
|
434
|
+
<nav className="sidebar-nav">
|
|
435
|
+
{orderedItems.map((item, i) => (
|
|
436
|
+
<DraggableSidebarItem
|
|
437
|
+
key={getItemId(item)}
|
|
438
|
+
item={item}
|
|
439
|
+
index={i}
|
|
440
|
+
path={ROOT_PATH}
|
|
441
|
+
/>
|
|
442
|
+
))}
|
|
443
|
+
</nav>
|
|
444
|
+
<div className="sidebar-footer">
|
|
445
|
+
<WidthToggle />
|
|
446
|
+
<ThemeToggle />
|
|
447
|
+
</div>
|
|
448
|
+
</>
|
|
449
|
+
)}
|
|
450
|
+
</aside>
|
|
451
|
+
<main className="prev-main">
|
|
452
|
+
{children}
|
|
453
|
+
</main>
|
|
454
|
+
</div>
|
|
455
|
+
</DragContext.Provider>
|
|
185
456
|
)
|
|
186
457
|
}
|
|
@@ -4,8 +4,6 @@ type FrontmatterValue = string | number | boolean | string[] | unknown
|
|
|
4
4
|
|
|
5
5
|
interface MetadataBlockProps {
|
|
6
6
|
frontmatter: Record<string, FrontmatterValue>
|
|
7
|
-
title?: string
|
|
8
|
-
description?: string
|
|
9
7
|
}
|
|
10
8
|
|
|
11
9
|
// Fields to skip (shown elsewhere or internal)
|
|
@@ -102,7 +100,7 @@ function renderValue(key: string, value: FrontmatterValue): React.ReactNode {
|
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
export function MetadataBlock({ frontmatter
|
|
103
|
+
export function MetadataBlock({ frontmatter }: MetadataBlockProps) {
|
|
106
104
|
// Filter out skipped fields and empty values
|
|
107
105
|
const fields = Object.entries(frontmatter).filter(
|
|
108
106
|
([key, value]) => !SKIP_FIELDS.has(key) && value !== undefined && value !== null && value !== ''
|
|
@@ -111,36 +109,27 @@ export function MetadataBlock({ frontmatter, title, description }: MetadataBlock
|
|
|
111
109
|
// Check for draft status
|
|
112
110
|
const isDraft = frontmatter.draft === true
|
|
113
111
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
const hasExplicitDescription = 'description' in frontmatter && frontmatter.description
|
|
117
|
-
|
|
118
|
-
// If no explicit metadata and no other fields, don't render the block
|
|
119
|
-
if (fields.length === 0 && !hasExplicitTitle && !hasExplicitDescription && !isDraft) {
|
|
112
|
+
// If no fields to show, don't render
|
|
113
|
+
if (fields.length === 0 && !isDraft) {
|
|
120
114
|
return null
|
|
121
115
|
}
|
|
122
116
|
|
|
123
117
|
return (
|
|
124
118
|
<div className="metadata-block">
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
</span>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{renderValue(key, value)}
|
|
140
|
-
</span>
|
|
141
|
-
))}
|
|
142
|
-
</div>
|
|
143
|
-
)}
|
|
119
|
+
<div className="metadata-fields">
|
|
120
|
+
{isDraft && (
|
|
121
|
+
<span className="metadata-field metadata-draft">
|
|
122
|
+
<span className="metadata-chip is-draft">Draft</span>
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
{fields.filter(([key]) => key !== 'draft').map(([key, value]) => (
|
|
126
|
+
<span key={key} className="metadata-field">
|
|
127
|
+
{FIELD_ICONS[key] && <span className="metadata-icon">{FIELD_ICONS[key]}</span>}
|
|
128
|
+
<span className="metadata-key">{key}:</span>
|
|
129
|
+
{renderValue(key, value)}
|
|
130
|
+
</span>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
144
133
|
</div>
|
|
145
134
|
)
|
|
146
135
|
}
|
package/src/theme/entry.tsx
CHANGED
|
@@ -74,11 +74,7 @@ function PageWrapper({ Component, meta }: { Component: React.ComponentType; meta
|
|
|
74
74
|
return (
|
|
75
75
|
<>
|
|
76
76
|
{meta.frontmatter && Object.keys(meta.frontmatter).length > 0 && (
|
|
77
|
-
<MetadataBlock
|
|
78
|
-
frontmatter={meta.frontmatter}
|
|
79
|
-
title={meta.title}
|
|
80
|
-
description={meta.description}
|
|
81
|
-
/>
|
|
77
|
+
<MetadataBlock frontmatter={meta.frontmatter} />
|
|
82
78
|
)}
|
|
83
79
|
<Component />
|
|
84
80
|
</>
|
package/src/theme/styles.css
CHANGED
|
@@ -165,6 +165,8 @@ body {
|
|
|
165
165
|
.sidebar-footer {
|
|
166
166
|
padding: 1rem;
|
|
167
167
|
border-top: 1px solid var(--fd-border);
|
|
168
|
+
display: flex;
|
|
169
|
+
gap: 0.5rem;
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
.theme-toggle {
|
|
@@ -597,25 +599,7 @@ body {
|
|
|
597
599
|
=========================================== */
|
|
598
600
|
|
|
599
601
|
.metadata-block {
|
|
600
|
-
margin-bottom:
|
|
601
|
-
padding-bottom: 1.5rem;
|
|
602
|
-
border-bottom: 1px solid var(--fd-border);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
.metadata-title {
|
|
606
|
-
font-size: 2.25rem;
|
|
607
|
-
font-weight: 700;
|
|
608
|
-
margin: 0 0 0.5rem 0;
|
|
609
|
-
line-height: 1.2;
|
|
610
|
-
letter-spacing: -0.025em;
|
|
611
|
-
color: var(--fd-foreground);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
.metadata-description {
|
|
615
|
-
font-size: 1.125rem;
|
|
616
|
-
color: var(--fd-muted-foreground);
|
|
617
|
-
margin: 0 0 1rem 0;
|
|
618
|
-
line-height: 1.5;
|
|
602
|
+
margin-bottom: 1.5rem;
|
|
619
603
|
}
|
|
620
604
|
|
|
621
605
|
.metadata-fields {
|
|
@@ -697,3 +681,54 @@ body {
|
|
|
697
681
|
.metadata-number {
|
|
698
682
|
font-variant-numeric: tabular-nums;
|
|
699
683
|
}
|
|
684
|
+
|
|
685
|
+
/* ===========================================
|
|
686
|
+
Full Width Mode
|
|
687
|
+
=========================================== */
|
|
688
|
+
|
|
689
|
+
.full-width .prev-content {
|
|
690
|
+
max-width: none;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* ===========================================
|
|
694
|
+
Drag and Drop Sidebar
|
|
695
|
+
=========================================== */
|
|
696
|
+
|
|
697
|
+
.sidebar-item-wrapper {
|
|
698
|
+
position: relative;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.drag-handle {
|
|
702
|
+
opacity: 0;
|
|
703
|
+
cursor: grab;
|
|
704
|
+
color: var(--fd-muted-foreground);
|
|
705
|
+
font-size: 0.75rem;
|
|
706
|
+
margin-right: 0.25rem;
|
|
707
|
+
transition: opacity 0.15s ease;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.sidebar-folder:hover .drag-handle,
|
|
711
|
+
.sidebar-item-wrapper:hover .drag-handle,
|
|
712
|
+
.sidebar-link:hover .drag-handle {
|
|
713
|
+
opacity: 0.5;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.drag-handle:hover {
|
|
717
|
+
opacity: 1 !important;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.sidebar-folder.drop-target,
|
|
721
|
+
.sidebar-item-wrapper.drop-target {
|
|
722
|
+
background: var(--fd-accent);
|
|
723
|
+
border-radius: 0.375rem;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.sidebar-folder[draggable="true"],
|
|
727
|
+
.sidebar-item-wrapper[draggable="true"] {
|
|
728
|
+
cursor: grab;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.sidebar-folder[draggable="true"]:active,
|
|
732
|
+
.sidebar-item-wrapper[draggable="true"]:active {
|
|
733
|
+
cursor: grabbing;
|
|
734
|
+
}
|