prev-cli 0.14.1 → 0.16.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.
@@ -0,0 +1,183 @@
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import { Link, useLocation } from '@tanstack/react-router'
3
+ import type { PageTree } from 'fumadocs-core/server'
4
+ import { IconX, IconChevronRight, IconFile, IconFolder } from '@tabler/icons-react'
5
+
6
+ interface TOCPanelProps {
7
+ tree: PageTree.Root
8
+ onClose: () => void
9
+ }
10
+
11
+ type TreeItem = PageTree.Item | PageTree.Folder
12
+
13
+ function getItemId(item: TreeItem): string {
14
+ return item.type === 'folder' ? `folder:${item.name}` : item.url
15
+ }
16
+
17
+ export function TOCPanel({ tree, onClose }: TOCPanelProps) {
18
+ const location = useLocation()
19
+ const panelRef = useRef<HTMLDivElement>(null)
20
+ const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' ? window.innerWidth <= 768 : false)
21
+ const [orderedItems, setOrderedItems] = useState<TreeItem[]>(tree.children)
22
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
23
+ const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
24
+
25
+ useEffect(() => {
26
+ const handleResize = () => setIsMobile(window.innerWidth <= 768)
27
+ window.addEventListener('resize', handleResize)
28
+ return () => window.removeEventListener('resize', handleResize)
29
+ }, [])
30
+
31
+ useEffect(() => {
32
+ const handleClickOutside = (e: MouseEvent) => {
33
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
34
+ onClose()
35
+ }
36
+ }
37
+ const handleEscape = (e: KeyboardEvent) => {
38
+ if (e.key === 'Escape') onClose()
39
+ }
40
+ document.addEventListener('mousedown', handleClickOutside)
41
+ document.addEventListener('keydown', handleEscape)
42
+ return () => {
43
+ document.removeEventListener('mousedown', handleClickOutside)
44
+ document.removeEventListener('keydown', handleEscape)
45
+ }
46
+ }, [onClose])
47
+
48
+ const handleDragStart = (index: number) => {
49
+ setDragIndex(index)
50
+ }
51
+
52
+ const handleDragOver = (index: number) => {
53
+ if (dragIndex !== null && dragIndex !== index) {
54
+ setDragOverIndex(index)
55
+ }
56
+ }
57
+
58
+ const handleDragEnd = async () => {
59
+ if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) {
60
+ const newItems = [...orderedItems]
61
+ const [removed] = newItems.splice(dragIndex, 1)
62
+ newItems.splice(dragOverIndex, 0, removed)
63
+ setOrderedItems(newItems)
64
+
65
+ // Auto-save to config
66
+ const order = newItems.map(item =>
67
+ item.type === 'folder' ? `${item.name}/` : item.url.replace(/^\//, '') + '.md'
68
+ )
69
+
70
+ await fetch('/__prev/config', {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ path: '/', order })
74
+ })
75
+ }
76
+ setDragIndex(null)
77
+ setDragOverIndex(null)
78
+ }
79
+
80
+ if (isMobile) {
81
+ return (
82
+ <div className="toc-overlay">
83
+ <div className="toc-overlay-content" ref={panelRef}>
84
+ <div className="toc-overlay-header">
85
+ <span>Navigation</span>
86
+ <button className="toc-close-btn" onClick={onClose}><IconX size={16} /></button>
87
+ </div>
88
+ <nav className="toc-nav">
89
+ {orderedItems.map((item) => (
90
+ <TOCItem
91
+ key={getItemId(item)}
92
+ item={item}
93
+ location={location}
94
+ onNavigate={onClose}
95
+ />
96
+ ))}
97
+ </nav>
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ return (
104
+ <div className="toc-dropdown" ref={panelRef}>
105
+ <div className="toc-dropdown-header">
106
+ <span>Navigation</span>
107
+ <button className="toc-close-btn" onClick={onClose}><IconX size={16} /></button>
108
+ </div>
109
+ <nav className="toc-nav">
110
+ {orderedItems.map((item, i) => (
111
+ <div
112
+ key={getItemId(item)}
113
+ draggable
114
+ onDragStart={() => handleDragStart(i)}
115
+ onDragOver={(e) => { e.preventDefault(); handleDragOver(i) }}
116
+ onDragEnd={handleDragEnd}
117
+ className={dragOverIndex === i ? 'drop-target' : ''}
118
+ >
119
+ <TOCItem
120
+ item={item}
121
+ location={location}
122
+ onNavigate={onClose}
123
+ />
124
+ </div>
125
+ ))}
126
+ </nav>
127
+ </div>
128
+ )
129
+ }
130
+
131
+ interface TOCItemProps {
132
+ item: TreeItem
133
+ location: { pathname: string }
134
+ onNavigate: () => void
135
+ depth?: number
136
+ }
137
+
138
+ function TOCItem({ item, location, onNavigate, depth = 0 }: TOCItemProps) {
139
+ const [isOpen, setIsOpen] = useState(true)
140
+
141
+ if (item.type === 'folder') {
142
+ return (
143
+ <div className="toc-folder">
144
+ <button
145
+ className="toc-folder-toggle"
146
+ onClick={() => setIsOpen(!isOpen)}
147
+ style={{ paddingLeft: `${depth * 12 + 8}px` }}
148
+ >
149
+ <IconChevronRight size={14} className={`folder-chevron ${isOpen ? 'open' : ''}`} />
150
+ <IconFolder size={14} className="toc-icon" />
151
+ {item.name}
152
+ </button>
153
+ {isOpen && (
154
+ <div className="toc-folder-children">
155
+ {item.children.map((child) => (
156
+ <TOCItem
157
+ key={getItemId(child)}
158
+ item={child}
159
+ location={location}
160
+ onNavigate={onNavigate}
161
+ depth={depth + 1}
162
+ />
163
+ ))}
164
+ </div>
165
+ )}
166
+ </div>
167
+ )
168
+ }
169
+
170
+ const isActive = location.pathname === item.url
171
+
172
+ return (
173
+ <Link
174
+ to={item.url}
175
+ className={`toc-link ${isActive ? 'active' : ''}`}
176
+ style={{ paddingLeft: `${depth * 12 + 12}px` }}
177
+ onClick={onNavigate}
178
+ >
179
+ <IconFile size={14} className="toc-icon" />
180
+ {item.name}
181
+ </Link>
182
+ )
183
+ }
@@ -0,0 +1,58 @@
1
+ .prev-toolbar {
2
+ position: fixed;
3
+ z-index: 9999;
4
+ display: flex;
5
+ align-items: center;
6
+ gap: 0.25rem;
7
+ padding: 0.375rem;
8
+ background: var(--fd-background);
9
+ border: 1px solid var(--fd-border);
10
+ border-radius: 9999px;
11
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
12
+ cursor: grab;
13
+ user-select: none;
14
+ }
15
+
16
+ .prev-toolbar:active {
17
+ cursor: grabbing;
18
+ }
19
+
20
+ .toolbar-btn {
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ width: 36px;
25
+ height: 36px;
26
+ background: transparent;
27
+ border: none;
28
+ border-radius: 50%;
29
+ color: var(--fd-muted-foreground);
30
+ cursor: pointer;
31
+ transition: all 0.15s ease;
32
+ text-decoration: none;
33
+ }
34
+
35
+ .toolbar-btn:hover {
36
+ background: var(--fd-muted);
37
+ color: var(--fd-foreground);
38
+ }
39
+
40
+ .toolbar-btn.active {
41
+ background: var(--fd-accent);
42
+ color: var(--fd-accent-foreground);
43
+ }
44
+
45
+ .toolbar-devtools-slot {
46
+ display: contents;
47
+ }
48
+
49
+ .toolbar-devtools-slot:empty {
50
+ display: none;
51
+ }
52
+
53
+ /* Hide width toggle on mobile */
54
+ @media (max-width: 768px) {
55
+ .toolbar-btn.desktop-only {
56
+ display: none;
57
+ }
58
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import { Link } from '@tanstack/react-router'
3
+ import type { PageTree } from 'fumadocs-core/server'
4
+ import { previews } from 'virtual:prev-previews'
5
+ import { IconMenu2, IconLayoutGrid, IconSun, IconMoon, IconArrowsMaximize, IconArrowsMinimize } from '@tabler/icons-react'
6
+ import './Toolbar.css'
7
+
8
+ interface ToolbarProps {
9
+ tree: PageTree.Root
10
+ onThemeToggle: () => void
11
+ onWidthToggle: () => void
12
+ isDark: boolean
13
+ isFullWidth: boolean
14
+ onTocToggle: () => void
15
+ tocOpen: boolean
16
+ }
17
+
18
+ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidth, onTocToggle, tocOpen }: ToolbarProps) {
19
+ const [position, setPosition] = useState({ x: 20, y: typeof window !== 'undefined' ? window.innerHeight - 80 : 600 })
20
+ const [dragging, setDragging] = useState(false)
21
+ const dragStart = useRef({ x: 0, y: 0 })
22
+ const toolbarRef = useRef<HTMLDivElement>(null)
23
+
24
+ const handleMouseDown = (e: React.MouseEvent) => {
25
+ if ((e.target as HTMLElement).closest('button, a')) return
26
+ setDragging(true)
27
+ dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }
28
+ }
29
+
30
+ useEffect(() => {
31
+ if (!dragging) return
32
+
33
+ const handleMouseMove = (e: MouseEvent) => {
34
+ setPosition({
35
+ x: Math.max(0, Math.min(window.innerWidth - 300, e.clientX - dragStart.current.x)),
36
+ y: Math.max(0, Math.min(window.innerHeight - 60, e.clientY - dragStart.current.y))
37
+ })
38
+ }
39
+
40
+ const handleMouseUp = () => setDragging(false)
41
+
42
+ document.addEventListener('mousemove', handleMouseMove)
43
+ document.addEventListener('mouseup', handleMouseUp)
44
+ return () => {
45
+ document.removeEventListener('mousemove', handleMouseMove)
46
+ document.removeEventListener('mouseup', handleMouseUp)
47
+ }
48
+ }, [dragging])
49
+
50
+ return (
51
+ <div
52
+ ref={toolbarRef}
53
+ className="prev-toolbar"
54
+ style={{ left: position.x, top: position.y }}
55
+ onMouseDown={handleMouseDown}
56
+ >
57
+ <button
58
+ className={`toolbar-btn ${tocOpen ? 'active' : ''}`}
59
+ onClick={onTocToggle}
60
+ title="Table of Contents"
61
+ >
62
+ <IconMenu2 size={18} />
63
+ </button>
64
+
65
+ {previews && previews.length > 0 && (
66
+ <Link to="/previews" className="toolbar-btn" title="Previews">
67
+ <IconLayoutGrid size={18} />
68
+ </Link>
69
+ )}
70
+
71
+ {/* Contextual devtools slot - populated by preview context */}
72
+ <div className="toolbar-devtools-slot" id="toolbar-devtools" />
73
+
74
+ <button
75
+ className="toolbar-btn desktop-only"
76
+ onClick={onWidthToggle}
77
+ title={isFullWidth ? 'Constrain width' : 'Full width'}
78
+ >
79
+ {isFullWidth ? <IconArrowsMinimize size={18} /> : <IconArrowsMaximize size={18} />}
80
+ </button>
81
+
82
+ <button
83
+ className="toolbar-btn"
84
+ onClick={onThemeToggle}
85
+ title={isDark ? 'Light mode' : 'Dark mode'}
86
+ >
87
+ {isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
88
+ </button>
89
+ </div>
90
+ )
91
+ }
@@ -1,2 +1,5 @@
1
1
  // Public exports for @prev/theme
2
+ export { Layout } from './Layout'
3
+ export { Toolbar } from './Toolbar'
4
+ export { TOCPanel } from './TOCPanel'
2
5
  export { Preview } from './Preview'
@@ -20,186 +20,6 @@ body {
20
20
  color: var(--fd-foreground);
21
21
  }
22
22
 
23
- /* Layout structure */
24
- .prev-layout {
25
- --sidebar-width: 260px;
26
- display: grid;
27
- grid-template-columns: var(--sidebar-width) 1fr;
28
- min-height: 100vh;
29
- transition: grid-template-columns 0.2s ease;
30
- }
31
-
32
- .prev-layout.sidebar-collapsed {
33
- --sidebar-width: 50px;
34
- }
35
-
36
- /* Sidebar */
37
- .prev-sidebar {
38
- position: sticky;
39
- top: 0;
40
- height: 100vh;
41
- width: var(--sidebar-width);
42
- background: var(--fd-background);
43
- border-right: 1px solid var(--fd-border);
44
- display: flex;
45
- flex-direction: column;
46
- overflow: hidden;
47
- transition: width 0.2s ease;
48
- }
49
-
50
- /* Sidebar header with toggle */
51
- .sidebar-header {
52
- padding: 0.75rem;
53
- border-bottom: 1px solid var(--fd-border);
54
- display: flex;
55
- justify-content: flex-start;
56
- }
57
-
58
- .sidebar-collapsed .sidebar-header {
59
- justify-content: center;
60
- }
61
-
62
- .sidebar-toggle {
63
- display: flex;
64
- align-items: center;
65
- justify-content: center;
66
- width: 36px;
67
- height: 36px;
68
- background: var(--fd-muted);
69
- border: none;
70
- border-radius: 0.5rem;
71
- color: var(--fd-muted-foreground);
72
- cursor: pointer;
73
- transition: all 0.15s ease;
74
- }
75
-
76
- .sidebar-toggle:hover {
77
- background: var(--fd-accent);
78
- color: var(--fd-accent-foreground);
79
- }
80
-
81
- /* Collapsed rail */
82
- .sidebar-rail {
83
- display: flex;
84
- flex-direction: column;
85
- align-items: center;
86
- padding: 0.5rem;
87
- gap: 0.5rem;
88
- flex: 1;
89
- overflow-y: auto;
90
- }
91
-
92
- .sidebar-rail-icon {
93
- display: flex;
94
- align-items: center;
95
- justify-content: center;
96
- width: 36px;
97
- height: 36px;
98
- background: transparent;
99
- border: none;
100
- border-radius: 0.5rem;
101
- color: var(--fd-muted-foreground);
102
- cursor: pointer;
103
- transition: all 0.15s ease;
104
- }
105
-
106
- .sidebar-rail-icon:hover {
107
- background: var(--fd-muted);
108
- color: var(--fd-foreground);
109
- }
110
-
111
- .sidebar-nav {
112
- flex: 1;
113
- overflow-y: auto;
114
- padding: 1rem 0.5rem;
115
- }
116
-
117
- .sidebar-link {
118
- display: block;
119
- padding: 0.5rem 1rem;
120
- color: var(--fd-muted-foreground);
121
- text-decoration: none;
122
- font-size: 0.9rem;
123
- border-radius: 0.375rem;
124
- transition: all 0.15s ease;
125
- }
126
-
127
- .sidebar-link:hover {
128
- background: var(--fd-muted);
129
- color: var(--fd-foreground);
130
- }
131
-
132
- .sidebar-link.active {
133
- background: var(--fd-accent);
134
- color: var(--fd-accent-foreground);
135
- font-weight: 500;
136
- }
137
-
138
- .sidebar-folder-toggle {
139
- display: flex;
140
- align-items: center;
141
- gap: 0.5rem;
142
- width: 100%;
143
- padding: 0.5rem 1rem;
144
- background: none;
145
- border: none;
146
- color: var(--fd-foreground);
147
- font-size: 0.9rem;
148
- font-weight: 500;
149
- cursor: pointer;
150
- border-radius: 0.375rem;
151
- text-align: left;
152
- }
153
-
154
- .sidebar-folder-toggle:hover {
155
- background: var(--fd-muted);
156
- }
157
-
158
- .folder-icon {
159
- display: flex;
160
- transition: transform 0.15s ease;
161
- }
162
-
163
- .folder-icon.open {
164
- transform: rotate(90deg);
165
- }
166
-
167
- .sidebar-folder-children {
168
- margin-left: 0.5rem;
169
- }
170
-
171
- .sidebar-footer {
172
- padding: 1rem;
173
- border-top: 1px solid var(--fd-border);
174
- display: flex;
175
- gap: 0.5rem;
176
- }
177
-
178
- .theme-toggle {
179
- display: flex;
180
- align-items: center;
181
- justify-content: center;
182
- width: 36px;
183
- height: 36px;
184
- background: var(--fd-muted);
185
- border: none;
186
- border-radius: 0.5rem;
187
- color: var(--fd-muted-foreground);
188
- cursor: pointer;
189
- transition: all 0.15s ease;
190
- }
191
-
192
- .theme-toggle:hover {
193
- background: var(--fd-accent);
194
- color: var(--fd-accent-foreground);
195
- }
196
-
197
- /* Main content area */
198
- .prev-main {
199
- padding: 2rem;
200
- overflow-x: hidden;
201
- }
202
-
203
23
  /* Custom fonts */
204
24
  :root {
205
25
  --fd-font-sans: "DM Sans", system-ui, sans-serif;
@@ -594,12 +414,6 @@ body {
594
414
  margin-top: 0;
595
415
  }
596
416
 
597
- /* Sidebar active item highlight */
598
- [data-active="true"] {
599
- background: var(--fd-accent);
600
- color: var(--fd-accent-foreground);
601
- }
602
-
603
417
  /* ===========================================
604
418
  Metadata Block Styles
605
419
  =========================================== */
@@ -697,44 +511,23 @@ body {
697
511
  }
698
512
 
699
513
  /* ===========================================
700
- Drag and Drop Sidebar
514
+ Floating Layout - New Toolbar-Based Layout
701
515
  =========================================== */
702
516
 
703
- .sidebar-item-wrapper {
704
- position: relative;
705
- }
706
-
707
- .drag-handle {
708
- opacity: 0;
709
- cursor: grab;
710
- color: var(--fd-muted-foreground);
711
- font-size: 0.75rem;
712
- margin-right: 0.25rem;
713
- transition: opacity 0.15s ease;
714
- }
715
-
716
- .sidebar-folder:hover .drag-handle,
717
- .sidebar-item-wrapper:hover .drag-handle,
718
- .sidebar-link:hover .drag-handle {
719
- opacity: 0.5;
720
- }
721
-
722
- .drag-handle:hover {
723
- opacity: 1 !important;
517
+ .prev-layout-floating {
518
+ min-height: 100vh;
724
519
  }
725
520
 
726
- .sidebar-folder.drop-target,
727
- .sidebar-item-wrapper.drop-target {
728
- background: var(--fd-accent);
729
- border-radius: 0.375rem;
521
+ .prev-main-floating {
522
+ padding: 2rem;
523
+ max-width: 100%;
730
524
  }
731
525
 
732
- .sidebar-folder[draggable="true"],
733
- .sidebar-item-wrapper[draggable="true"] {
734
- cursor: grab;
526
+ .prev-main-floating .prev-content {
527
+ max-width: 72ch;
528
+ margin: 0 auto;
735
529
  }
736
530
 
737
- .sidebar-folder[draggable="true"]:active,
738
- .sidebar-item-wrapper[draggable="true"]:active {
739
- cursor: grabbing;
531
+ .full-width .prev-main-floating .prev-content {
532
+ max-width: none;
740
533
  }