@dfosco/storyboard-react 4.1.0 → 4.2.0-beta.1

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.
@@ -1,7 +1,15 @@
1
1
  import { useCallback, useRef, useState, useEffect } from 'react'
2
- import { createCanvas } from './canvasApi.js'
2
+ import { createCanvas, renamePage, reorderPages, getPageOrder, duplicateCanvas } from './canvasApi.js'
3
3
  import styles from './PageSelector.module.css'
4
4
 
5
+ const DragGrip = () => (
6
+ <svg className={styles.dragHandle} width="8" height="14" viewBox="0 0 8 14" fill="currentColor" aria-hidden="true">
7
+ <circle cx="2" cy="2" r="1.2" /><circle cx="6" cy="2" r="1.2" />
8
+ <circle cx="2" cy="7" r="1.2" /><circle cx="6" cy="7" r="1.2" />
9
+ <circle cx="2" cy="12" r="1.2" /><circle cx="6" cy="12" r="1.2" />
10
+ </svg>
11
+ )
12
+
5
13
  /**
6
14
  * In-canvas page selector — shows sibling pages in the same canvas group.
7
15
  * Only renders when 2+ sibling pages exist.
@@ -24,33 +32,294 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
24
32
  const [creating, setCreating] = useState(false)
25
33
  const [pages, setPages] = useState(initialPages)
26
34
  const [successMsg, setSuccessMsg] = useState(null)
35
+ const [editingPage, setEditingPage] = useState(null)
36
+ const [editValue, setEditValue] = useState('')
37
+ const [orderedItems, setOrderedItems] = useState(() => initialPages.map(p => ({ type: 'page', ...p })))
38
+ const [dragIndex, setDragIndex] = useState(null)
39
+ const [dropTarget, setDropTarget] = useState(null)
27
40
  const containerRef = useRef(null)
28
41
  const inputRef = useRef(null)
42
+ const editInputRef = useRef(null)
43
+ const clickTimerRef = useRef(null)
44
+ const didDragRef = useRef(false)
29
45
 
30
46
  // Sync pages when prop changes (e.g. HMR reload)
31
47
  useEffect(() => { setPages(initialPages) }, [initialPages])
32
48
 
33
- const currentPage = pages.find((p) => p.name === currentName)
34
- const currentLabel = currentPage?.title || currentName.split('/').pop()
35
- const currentIndex = pages.findIndex((p) => p.name === currentName)
49
+ // Keep orderedItems in sync with pages when dropdown is closed
50
+ useEffect(() => {
51
+ setOrderedItems(prev => {
52
+ // If we have order with separators, preserve it but update page data
53
+ if (prev.some(i => i.type === 'separator')) return prev
54
+ return initialPages.map(p => ({ type: 'page', ...p }))
55
+ })
56
+ }, [initialPages])
36
57
 
37
58
  // Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
38
59
  const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
39
60
 
61
+ // Build ordered items from pages + saved order (with separators).
62
+ // Load eagerly (not just when open) so the trigger badge shows correct index.
63
+ useEffect(() => {
64
+ let cancelled = false
65
+ async function loadOrder() {
66
+ if (!folder) {
67
+ setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
68
+ return
69
+ }
70
+ try {
71
+ const result = await getPageOrder(folder)
72
+ if (cancelled) return
73
+ if (result?.order) {
74
+ const items = []
75
+ const pageMap = new Map(pages.map(p => [p.name, p]))
76
+ const seen = new Set()
77
+ for (const entry of result.order) {
78
+ if (typeof entry === 'string' && entry.startsWith('sep-')) {
79
+ items.push({ type: 'separator', id: entry })
80
+ } else if (pageMap.has(entry)) {
81
+ items.push({ type: 'page', ...pageMap.get(entry) })
82
+ seen.add(entry)
83
+ }
84
+ }
85
+ // Append pages not in saved order
86
+ for (const p of pages) {
87
+ if (!seen.has(p.name)) items.push({ type: 'page', ...p })
88
+ }
89
+ setOrderedItems(items)
90
+ } else {
91
+ setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
92
+ }
93
+ } catch {
94
+ setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
95
+ }
96
+ }
97
+ loadOrder()
98
+ return () => { cancelled = true }
99
+ }, [pages, folder])
100
+
101
+ // Derived values from ordered items
102
+ const realPages = orderedItems.filter(i => i.type === 'page')
103
+ const currentPage = realPages.find(p => p.name === currentName)
104
+ const currentLabel = currentPage?.title || currentName.split('/').pop()
105
+ const currentIndex = realPages.findIndex(p => p.name === currentName)
106
+
107
+ const navigateTo = useCallback((page) => {
108
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
109
+ window.location.href = base + page.route
110
+ }, [])
111
+
40
112
  const handleSelect = useCallback(
41
113
  (page) => {
42
114
  if (page.name !== currentName) {
43
- const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
44
- window.location.href = base + page.route
115
+ navigateTo(page)
45
116
  }
46
117
  setOpen(false)
47
118
  },
48
- [currentName],
119
+ [currentName, navigateTo],
49
120
  )
50
121
 
122
+ // Click handler with 300ms delay (mouse only) to distinguish from dblclick
123
+ const handleItemClick = useCallback((page, e) => {
124
+ if (didDragRef.current) {
125
+ didDragRef.current = false
126
+ return
127
+ }
128
+ if (editingPage) return
129
+ // Keyboard Enter/Space → navigate immediately
130
+ if (!e?.nativeEvent || e.nativeEvent instanceof KeyboardEvent) {
131
+ handleSelect(page)
132
+ return
133
+ }
134
+ // Mouse click → delay to allow dblclick
135
+ if (clickTimerRef.current) clearTimeout(clickTimerRef.current)
136
+ clickTimerRef.current = setTimeout(() => {
137
+ clickTimerRef.current = null
138
+ handleSelect(page)
139
+ }, 300)
140
+ }, [editingPage, handleSelect])
141
+
142
+ // Double-click to rename
143
+ const handleItemDblClick = useCallback((page) => {
144
+ if (!isLocalDev) return
145
+ if (clickTimerRef.current) {
146
+ clearTimeout(clickTimerRef.current)
147
+ clickTimerRef.current = null
148
+ }
149
+ setEditingPage(page.name)
150
+ setEditValue(page.title)
151
+ }, [isLocalDev])
152
+
153
+ // Focus edit input when entering edit mode
154
+ useEffect(() => {
155
+ if (editingPage && editInputRef.current) {
156
+ editInputRef.current.focus()
157
+ editInputRef.current.select()
158
+ }
159
+ }, [editingPage])
160
+
161
+ // Commit rename
162
+ const handleRenameCommit = useCallback(async () => {
163
+ const trimmed = editValue.trim()
164
+ const oldName = editingPage
165
+ setEditingPage(null)
166
+ if (!trimmed || !oldName) return
167
+
168
+ const oldPage = realPages.find(p => p.name === oldName)
169
+ if (!oldPage || trimmed === oldPage.title) return
170
+
171
+ // Validate no duplicates (case-insensitive)
172
+ const lower = trimmed.toLowerCase()
173
+ if (realPages.some(p => p.name !== oldName && p.title.toLowerCase() === lower)) {
174
+ console.warn('Duplicate page name:', trimmed)
175
+ return
176
+ }
177
+
178
+ try {
179
+ const result = await renamePage(oldName, trimmed)
180
+ if (result?.error) {
181
+ console.error('Failed to rename page:', result.error)
182
+ return
183
+ }
184
+ const route = result?.route
185
+ if (route) {
186
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
187
+ const targetUrl = base + route
188
+ try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
189
+ if (import.meta.hot) {
190
+ const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
191
+ import.meta.hot.on('vite:beforeFullReload', () => {
192
+ clearTimeout(timer)
193
+ sessionStorage.setItem('sb-pending-navigate', targetUrl)
194
+ })
195
+ } else {
196
+ setTimeout(() => { window.location.href = targetUrl }, 1000)
197
+ }
198
+ }
199
+ } catch (err) {
200
+ console.error('Failed to rename page:', err)
201
+ }
202
+ }, [editValue, editingPage, realPages])
203
+
204
+ // Duplicate a page
205
+ const handleDuplicate = useCallback(async (page, e) => {
206
+ e.stopPropagation()
207
+
208
+ // Smart copy naming: if title ends with ` N` or `vN`, increment the number.
209
+ // Skip `#N` (could be issue refs) and years (2000-2099).
210
+ const numberedMatch = page.title.match(/^(.+?)(\s+|v)(\d+)$/)
211
+ let copyTitle
212
+ const num = numberedMatch ? parseInt(numberedMatch[3], 10) : null
213
+ const isYear = num !== null && num >= 2000 && num <= 2099
214
+ if (numberedMatch && !isYear) {
215
+ const [, base, sep, numStr] = numberedMatch
216
+ let next = parseInt(numStr, 10) + 1
217
+ const titles = new Set(realPages.map(p => p.title))
218
+ while (titles.has(`${base}${sep}${next}`)) next++
219
+ copyTitle = `${base}${sep}${next}`
220
+ } else {
221
+ const baseTitle = page.title.replace(/ Copy( \d+)?$/, '')
222
+ const existingCopies = realPages
223
+ .filter(p => {
224
+ const t = p.title
225
+ return t === `${baseTitle} Copy` || (/^.+ Copy \d+$/.test(t) && t.startsWith(baseTitle))
226
+ })
227
+ .length
228
+ copyTitle = existingCopies === 0
229
+ ? `${baseTitle} Copy`
230
+ : `${baseTitle} Copy ${existingCopies + 1}`
231
+ }
232
+
233
+ try {
234
+ const result = await duplicateCanvas(page.name, copyTitle)
235
+ if (result?.error) {
236
+ console.error('Failed to duplicate page:', result.error)
237
+ return
238
+ }
239
+ const route = result?.route
240
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
241
+ const targetUrl = base + route
242
+
243
+ // Optimistically add the new page to the list
244
+ const pageName = result.name || copyTitle
245
+ setPages(prev => [...prev, { name: pageName, route, title: copyTitle }])
246
+ setSuccessMsg(`"${copyTitle}" created`)
247
+
248
+ try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
249
+
250
+ if (import.meta.hot) {
251
+ const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
252
+ import.meta.hot.on('vite:beforeFullReload', () => {
253
+ clearTimeout(timer)
254
+ sessionStorage.setItem('sb-pending-navigate', targetUrl)
255
+ })
256
+ } else {
257
+ setTimeout(() => { window.location.href = targetUrl }, 1000)
258
+ }
259
+ } catch (err) {
260
+ console.error('Failed to duplicate page:', err)
261
+ }
262
+ }, [realPages])
263
+
264
+ // Drag and drop handlers
265
+ const handleDragStart = useCallback((index, e) => {
266
+ didDragRef.current = true
267
+ setDragIndex(index)
268
+ e.dataTransfer.effectAllowed = 'move'
269
+ e.dataTransfer.setData('text/plain', String(index))
270
+ }, [])
271
+
272
+ const handleDragOver = useCallback((index, e) => {
273
+ e.preventDefault()
274
+ e.dataTransfer.dropEffect = 'move'
275
+ setDropTarget(index)
276
+ }, [])
277
+
278
+ const handleDragEnd = useCallback(() => {
279
+ setDragIndex(null)
280
+ setDropTarget(null)
281
+ }, [])
282
+
283
+ const handleDrop = useCallback(async (toIndex, e) => {
284
+ e.preventDefault()
285
+ const fromIndex = dragIndex
286
+ setDragIndex(null)
287
+ setDropTarget(null)
288
+ if (fromIndex == null || fromIndex === toIndex) return
289
+
290
+ const newItems = [...orderedItems]
291
+ const [moved] = newItems.splice(fromIndex, 1)
292
+ newItems.splice(toIndex, 0, moved)
293
+ setOrderedItems(newItems)
294
+
295
+ if (folder) {
296
+ const order = newItems.map(i => i.type === 'separator' ? i.id : i.name)
297
+ try { await reorderPages(folder, order) } catch (err) {
298
+ console.error('Failed to persist page order:', err)
299
+ }
300
+ }
301
+ }, [dragIndex, orderedItems, folder])
302
+
51
303
  const handleAddPage = useCallback(async () => {
52
304
  const trimmed = newName.trim()
53
305
  if (!trimmed || creating) return
306
+
307
+ // Separator shortcut
308
+ if (trimmed === '---') {
309
+ const sepId = `sep-${Date.now()}`
310
+ const newItems = [...orderedItems, { type: 'separator', id: sepId }]
311
+ setOrderedItems(newItems)
312
+ setAdding(false)
313
+ setNewName('')
314
+ if (folder) {
315
+ const order = newItems.map(i => i.type === 'separator' ? i.id : i.name)
316
+ try { await reorderPages(folder, order) } catch (err) {
317
+ console.error('Failed to persist separator:', err)
318
+ }
319
+ }
320
+ return
321
+ }
322
+
54
323
  setCreating(true)
55
324
  try {
56
325
  // Single-page canvas (no folder) → convert to multi-page folder
@@ -98,7 +367,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
98
367
  console.error('Failed to create canvas page:', err)
99
368
  setCreating(false)
100
369
  }
101
- }, [newName, currentName, folder, creating])
370
+ }, [newName, currentName, folder, creating, orderedItems])
102
371
 
103
372
  // Focus input when entering add mode
104
373
  useEffect(() => {
@@ -114,6 +383,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
114
383
  setAdding(false)
115
384
  setNewName('')
116
385
  setSuccessMsg(null)
386
+ setEditingPage(null)
117
387
  }
118
388
  }
119
389
  document.addEventListener('mousedown', handleClick)
@@ -125,7 +395,9 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
125
395
  if (!open) return
126
396
  function handleKey(e) {
127
397
  if (e.key === 'Escape') {
128
- if (adding) {
398
+ if (editingPage) {
399
+ setEditingPage(null)
400
+ } else if (adding) {
129
401
  setAdding(false)
130
402
  setNewName('')
131
403
  } else {
@@ -135,7 +407,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
135
407
  }
136
408
  document.addEventListener('keydown', handleKey)
137
409
  return () => document.removeEventListener('keydown', handleKey)
138
- }, [open, adding])
410
+ }, [open, adding, editingPage])
139
411
 
140
412
  // Show selector when there are multiple pages, or in dev mode (to allow adding pages)
141
413
  if (!pages || (pages.length < 2 && !isLocalDev)) return null
@@ -151,7 +423,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
151
423
  >
152
424
  <span className={styles.label}>{currentLabel}</span>
153
425
  <span className={styles.badge}>
154
- {currentIndex + 1}/{pages.length}
426
+ {currentIndex + 1}/{realPages.length}
155
427
  </span>
156
428
  <svg
157
429
  className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
@@ -166,29 +438,96 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
166
438
  </button>
167
439
  {open && (
168
440
  <ul className={styles.menu} role="listbox" aria-label="Canvas pages">
169
- {pages.map((page) => (
441
+ {orderedItems.map((item, index) => {
442
+ if (item.type === 'separator') {
443
+ return (
444
+ <li
445
+ key={item.id}
446
+ className={`${styles.separatorRow} ${dragIndex === index ? styles.itemDragging : ''}`}
447
+ role="separator"
448
+ draggable={isLocalDev}
449
+ onDragStart={(e) => handleDragStart(index, e)}
450
+ onDragOver={(e) => handleDragOver(index, e)}
451
+ onDrop={(e) => handleDrop(index, e)}
452
+ onDragEnd={handleDragEnd}
453
+ >
454
+ {dropTarget === index && dragIndex !== index && <div className={styles.dropIndicator} />}
455
+ {isLocalDev && <DragGrip />}
456
+ <div className={styles.separatorLine} />
457
+ </li>
458
+ )
459
+ }
460
+ const page = item
461
+ const isEditing = editingPage === page.name
462
+ return (
463
+ <li
464
+ key={page.name}
465
+ role="option"
466
+ aria-selected={page.name === currentName}
467
+ className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''} ${dragIndex === index ? styles.itemDragging : ''}`}
468
+ onClick={(e) => handleItemClick(page, e)}
469
+ onDoubleClick={() => handleItemDblClick(page)}
470
+ onKeyDown={(e) => {
471
+ if (e.key === 'Enter' || e.key === ' ') {
472
+ e.preventDefault()
473
+ handleSelect(page)
474
+ }
475
+ }}
476
+ tabIndex={0}
477
+ draggable={isLocalDev && !isEditing}
478
+ onDragStart={(e) => handleDragStart(index, e)}
479
+ onDragOver={(e) => handleDragOver(index, e)}
480
+ onDrop={(e) => handleDrop(index, e)}
481
+ onDragEnd={handleDragEnd}
482
+ >
483
+ {dropTarget === index && dragIndex !== index && <div className={styles.dropIndicator} />}
484
+ {isLocalDev && <DragGrip />}
485
+ {isEditing ? (
486
+ <input
487
+ ref={editInputRef}
488
+ className={styles.addInput}
489
+ type="text"
490
+ value={editValue}
491
+ onChange={(e) => setEditValue(e.target.value)}
492
+ onKeyDown={(e) => {
493
+ if (e.key === 'Enter') { e.preventDefault(); handleRenameCommit() }
494
+ if (e.key === 'Escape') { e.preventDefault(); setEditingPage(null) }
495
+ e.stopPropagation()
496
+ }}
497
+ onClick={(e) => e.stopPropagation()}
498
+ onDoubleClick={(e) => e.stopPropagation()}
499
+ onBlur={handleRenameCommit}
500
+ />
501
+ ) : (
502
+ <>
503
+ <span className={styles.itemContent}>{page.title}</span>
504
+ {isLocalDev && (
505
+ <button
506
+ className={styles.duplicateBtn}
507
+ onClick={(e) => handleDuplicate(page, e)}
508
+ onDoubleClick={(e) => e.stopPropagation()}
509
+ title="Duplicate page"
510
+ aria-label="Duplicate page"
511
+ >
512
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
513
+ <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
514
+ </svg>
515
+ </button>
516
+ )}
517
+ </>
518
+ )}
519
+ </li>
520
+ )
521
+ })}
522
+ {isLocalDev && (
170
523
  <li
171
- key={page.name}
172
- role="option"
173
- aria-selected={page.name === currentName}
174
- className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
175
- onClick={() => handleSelect(page)}
176
- onKeyDown={(e) => {
177
- if (e.key === 'Enter' || e.key === ' ') {
178
- e.preventDefault()
179
- handleSelect(page)
180
- }
181
- }}
182
- tabIndex={0}
524
+ className={styles.dropZoneEnd}
525
+ onDragOver={(e) => handleDragOver(orderedItems.length, e)}
526
+ onDrop={(e) => handleDrop(orderedItems.length, e)}
183
527
  >
184
- {page.title}
185
- </li>
186
- ))}
187
- {isLocalDev && (
188
- <>
189
- <li className={styles.separator} role="separator" />
528
+ {dropTarget === orderedItems.length && dragIndex != null && <div className={styles.dropIndicator} />}
190
529
  {adding ? (
191
- <li className={styles.addForm}>
530
+ <div className={styles.addForm}>
192
531
  <input
193
532
  ref={inputRef}
194
533
  className={styles.addInput}
@@ -211,9 +550,9 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
211
550
  >
212
551
  {creating ? '…' : 'Add'}
213
552
  </button>
214
- </li>
553
+ </div>
215
554
  ) : (
216
- <li
555
+ <div
217
556
  className={styles.addItem}
218
557
  onClick={() => setAdding(true)}
219
558
  tabIndex={0}
@@ -224,13 +563,13 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
224
563
  }
225
564
  }}
226
565
  >
227
- + Add new page
228
- </li>
566
+ + Add page
567
+ </div>
229
568
  )}
230
569
  {successMsg && (
231
- <li className={styles.successMsg}>✓ {successMsg}</li>
570
+ <div className={styles.successMsg}>✓ {successMsg}</div>
232
571
  )}
233
- </>
572
+ </li>
234
573
  )}
235
574
  </ul>
236
575
  )}
@@ -51,12 +51,12 @@
51
51
  position: absolute;
52
52
  top: calc(100% + 4px);
53
53
  left: 0;
54
- min-width: 180px;
55
- max-width: 300px;
54
+ min-width: 260px;
55
+ max-width: 360px;
56
56
  max-height: 320px;
57
57
  overflow-y: auto;
58
58
  margin: 0;
59
- padding: 4px;
59
+ padding: 6px;
60
60
  list-style: none;
61
61
  background: var(--bgColor-default, #fff);
62
62
  border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
@@ -65,13 +65,78 @@
65
65
  }
66
66
 
67
67
  .item {
68
- padding: 6px 10px;
68
+ position: relative;
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 4px;
72
+ padding: 8px 10px 8px 4px;
69
73
  border-radius: 4px;
70
74
  cursor: pointer;
71
- white-space: nowrap;
75
+ color: var(--fgColor-default, #1f2328);
76
+ }
77
+
78
+ .itemContent {
79
+ flex: 1;
80
+ min-width: 0;
72
81
  overflow: hidden;
73
82
  text-overflow: ellipsis;
83
+ white-space: nowrap;
84
+ }
85
+
86
+ .dragHandle {
87
+ opacity: 0;
88
+ color: var(--fgColor-muted, #656d76);
89
+ cursor: grab;
90
+ flex-shrink: 0;
91
+ padding: 2px;
92
+ }
93
+
94
+ .item:hover .dragHandle {
95
+ opacity: 1;
96
+ }
97
+
98
+ .duplicateBtn {
99
+ opacity: 0;
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ padding: 2px;
104
+ border: none;
105
+ border-radius: 4px;
106
+ background: transparent;
107
+ color: var(--fgColor-muted, #656d76);
108
+ cursor: pointer;
109
+ flex-shrink: 0;
110
+ }
111
+
112
+ .duplicateBtn:hover {
74
113
  color: var(--fgColor-default, #1f2328);
114
+ background: var(--bgColor-neutral-muted, rgba(0, 0, 0, 0.06));
115
+ }
116
+
117
+ .item:hover .duplicateBtn {
118
+ opacity: 1;
119
+ }
120
+
121
+ .itemDragging {
122
+ opacity: 0.5;
123
+ }
124
+
125
+ .dropIndicator {
126
+ position: absolute;
127
+ top: -1px;
128
+ left: 4px;
129
+ right: 4px;
130
+ height: 2px;
131
+ background: var(--focus-outlineColor, #0969da);
132
+ border-radius: 1px;
133
+ pointer-events: none;
134
+ }
135
+
136
+ .dropZoneEnd {
137
+ position: relative;
138
+ padding-top: 8px;
139
+ list-style: none;
75
140
  }
76
141
 
77
142
  .item:hover {
@@ -99,6 +164,27 @@
99
164
  list-style: none;
100
165
  }
101
166
 
167
+ .separatorRow {
168
+ position: relative;
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 4px;
172
+ padding: 6px 10px 6px 4px;
173
+ border-radius: 4px;
174
+ list-style: none;
175
+ cursor: default;
176
+ }
177
+
178
+ .separatorRow:hover .dragHandle {
179
+ opacity: 1;
180
+ }
181
+
182
+ .separatorLine {
183
+ flex: 1;
184
+ height: 1px;
185
+ background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
186
+ }
187
+
102
188
  .addItem {
103
189
  padding: 6px 10px;
104
190
  border-radius: 4px;
@@ -106,10 +192,11 @@
106
192
  white-space: nowrap;
107
193
  color: var(--fgColor-muted, #656d76);
108
194
  font-size: 12px;
195
+ background: var(--bgColor-muted, #f6f8fa);
109
196
  }
110
197
 
111
198
  .addItem:hover {
112
- background: var(--bgColor-muted, #f6f8fa);
199
+ background: var(--bgColor-neutral-muted, rgba(0, 0, 0, 0.08));
113
200
  color: var(--fgColor-default, #1f2328);
114
201
  }
115
202
 
@@ -61,3 +61,38 @@ export function checkGitHubCliAvailable() {
61
61
  export function fetchGitHubEmbed(url) {
62
62
  return request('/github/embed', 'POST', { url })
63
63
  }
64
+
65
+ export function renamePage(canvasId, newTitle) {
66
+ return request('/rename-page', 'PUT', { name: canvasId, newTitle })
67
+ }
68
+
69
+ export function reorderPages(folder, order) {
70
+ return request('/reorder-pages', 'PUT', { folder, order })
71
+ }
72
+
73
+ export function getPageOrder(folder) {
74
+ return request(`/page-order?folder=${encodeURIComponent(folder)}`, 'GET')
75
+ }
76
+
77
+ export function updateFolderMeta(folder, title) {
78
+ return request('/update-folder-meta', 'PUT', { folder, title })
79
+ }
80
+
81
+ export function duplicateCanvas(canvasId, newTitle) {
82
+ return request('/duplicate', 'POST', { name: canvasId, newTitle })
83
+ }
84
+
85
+ export function addConnector(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType }) {
86
+ return request('/connector', 'POST', {
87
+ name: canvasId,
88
+ startWidgetId,
89
+ startAnchor,
90
+ endWidgetId,
91
+ endAnchor,
92
+ connectorType,
93
+ })
94
+ }
95
+
96
+ export function removeConnector(canvasId, connectorId) {
97
+ return request('/connector', 'DELETE', { name: canvasId, connectorId })
98
+ }