@dfosco/storyboard-react 4.0.0-beta.36 → 4.0.0-beta.38

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,1170 @@
1
+ /**
2
+ * ViewfinderNew — SaaS-style homescreen for Storyboard.
3
+ *
4
+ * Replaces the old list-based Viewfinder with a sidebar + grid layout.
5
+ * Wired to real data from buildPrototypeIndex and listStories.
6
+ */
7
+ import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
8
+ import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
9
+ import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
10
+ import { Menu } from '@base-ui/react/menu'
11
+ import Icon from './Icon.jsx'
12
+ import css from './ViewfinderNew.module.css'
13
+
14
+ /* ─── localStorage helpers ─── */
15
+
16
+ const STARRED_KEY = 'sb-viewfinder-starred'
17
+ const RECENT_KEY = 'sb-viewfinder-recent'
18
+ const MAX_RECENT = 30
19
+ const GROUP_BY_FOLDERS_KEY = 'sb-viewfinder-group-folders'
20
+
21
+ function readJSON(key, fallback) {
22
+ try { return JSON.parse(localStorage.getItem(key)) || fallback }
23
+ catch { return fallback }
24
+ }
25
+
26
+ function writeJSON(key, value) {
27
+ localStorage.setItem(key, JSON.stringify(value))
28
+ window.dispatchEvent(new StorageEvent('storage', { key }))
29
+ }
30
+
31
+ function createLocalStorageStore(key, fallback) {
32
+ const subscribe = (cb) => {
33
+ const handler = (e) => { if (!e.key || e.key === key) cb() }
34
+ window.addEventListener('storage', handler)
35
+ return () => window.removeEventListener('storage', handler)
36
+ }
37
+ const getSnapshot = () => localStorage.getItem(key) || JSON.stringify(fallback)
38
+ return { subscribe, getSnapshot }
39
+ }
40
+
41
+ const starredStore = createLocalStorageStore(STARRED_KEY, [])
42
+ const recentStore = createLocalStorageStore(RECENT_KEY, [])
43
+
44
+ function useStarred() {
45
+ const raw = useSyncExternalStore(starredStore.subscribe, starredStore.getSnapshot)
46
+ const ids = JSON.parse(raw)
47
+ const toggle = useCallback((id) => {
48
+ const current = readJSON(STARRED_KEY, [])
49
+ const next = current.includes(id) ? current.filter(x => x !== id) : [...current, id]
50
+ writeJSON(STARRED_KEY, next)
51
+ }, [])
52
+ return { starred: new Set(ids), toggle }
53
+ }
54
+
55
+ function useRecent() {
56
+ const raw = useSyncExternalStore(recentStore.subscribe, recentStore.getSnapshot)
57
+ return JSON.parse(raw)
58
+ }
59
+
60
+ function trackRecent(id) {
61
+ const current = readJSON(RECENT_KEY, [])
62
+ const next = [id, ...current.filter(x => x !== id)].slice(0, MAX_RECENT)
63
+ writeJSON(RECENT_KEY, next)
64
+ }
65
+
66
+ /* ─── URL helpers ─── */
67
+
68
+ function withBase(basePath, route) {
69
+ const normalizedRoute = route.startsWith('/') ? route : `/${route}`
70
+ const normalizedBase = (basePath || '/').replace(/\/+$/, '')
71
+ if (!normalizedBase || normalizedBase === '/') return normalizedRoute
72
+ return `${normalizedBase}${normalizedRoute}`.replace(/\/+/g, '/')
73
+ }
74
+
75
+ /* ─── Thumbnail color from name hash ─── */
76
+
77
+ const THUMB_CLASSES = ['thumbBlue', 'thumbAmber', 'thumbGreen', 'thumbPurple', 'thumbRose', 'thumbSlate']
78
+
79
+ function thumbClass(name) {
80
+ let h = 0
81
+ for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
82
+ return css[THUMB_CLASSES[Math.abs(h) % THUMB_CLASSES.length]]
83
+ }
84
+
85
+ /* ─── Type helpers ─── */
86
+
87
+ function getTypeLabel(type) {
88
+ if (type === 'prototype') return 'PROTOTYPE'
89
+ if (type === 'canvas') return 'CANVAS'
90
+ if (type === 'component') return 'COMPONENT'
91
+ return type?.toUpperCase() || ''
92
+ }
93
+
94
+ function getTypeIcon(type, size = 14) {
95
+ if (type === 'prototype') return <Icon name="prototype" size={size} />
96
+ if (type === 'canvas') return <Icon name="canvas" size={size} />
97
+ if (type === 'component') return <Icon name="component" size={size} />
98
+ return null
99
+ }
100
+
101
+ /* ─── Avatar Stack ─── */
102
+
103
+ function AvatarStack({ authors }) {
104
+ if (!authors || authors.length === 0) return null
105
+ const list = Array.isArray(authors) ? authors : [authors]
106
+ return (
107
+ <div className={css.avatarStack}>
108
+ {list.map(username => (
109
+ <img
110
+ key={username}
111
+ className={css.avatarImg}
112
+ src={`https://github.com/${username}.png`}
113
+ alt={username}
114
+ width={24}
115
+ height={24}
116
+ loading="lazy"
117
+ />
118
+ ))}
119
+ </div>
120
+ )
121
+ }
122
+
123
+ /* ─── Star Button ─── */
124
+
125
+ function StarBtn({ active, onClick }) {
126
+ return (
127
+ <button
128
+ className={active ? css.iconBtnActive : css.iconBtn}
129
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick() }}
130
+ aria-label={active ? 'Remove favorite' : 'Favorite'}
131
+ title={active ? 'Remove favorite' : 'Favorite'}
132
+ >
133
+ {active ? <StarFillIcon size={16} /> : <StarIcon size={16} />}
134
+ </button>
135
+ )
136
+ }
137
+
138
+ /* ─── Artifact Card ─── */
139
+
140
+ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
141
+ const href = item.route ? withBase(basePath, item.route) : '#'
142
+ const isExternal = item.isExternal
143
+
144
+ const handleClick = () => {
145
+ trackRecent(item.id)
146
+ }
147
+
148
+ const Tag = isExternal ? 'a' : 'a'
149
+ const linkProps = isExternal
150
+ ? { href: item.externalUrl, target: '_blank', rel: 'noopener noreferrer' }
151
+ : { href }
152
+
153
+ const authorList = item.author
154
+ ? (Array.isArray(item.author) ? item.author : [item.author])
155
+ : item.gitAuthor ? [item.gitAuthor] : []
156
+
157
+ return (
158
+ <Tag className={css.card} {...linkProps} onClick={handleClick}>
159
+ <div className={css.cardHeader}>
160
+ <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
161
+ <div className={css.cardActions}>
162
+ {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
163
+ <StarBtn active={starred} onClick={() => onToggleStar(item.id)} />
164
+ </div>
165
+ </div>
166
+ <div className={css.cardBody}>
167
+ <div className={css.cardBodyContent}>
168
+ <div className={css.cardTitle}>
169
+ {item.name}
170
+ {isExternal && <span className={css.externalBadge}>↗</span>}
171
+ </div>
172
+ {item.description && (
173
+ <div className={css.cardDescription}>{item.description}</div>
174
+ )}
175
+ <div className={css.cardFooter}>
176
+ <AvatarStack authors={authorList} />
177
+ <div className={css.cardMeta}>
178
+ {authorList.length > 0 && <span>{authorList.join(', ')}</span>}
179
+ {authorList.length > 0 && formatRelativeTime(item.lastModified) && <span className={css.cardMetaDot} />}
180
+ {formatRelativeTime(item.lastModified) && <span>{formatRelativeTime(item.lastModified)}</span>}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </Tag>
186
+ )
187
+ }
188
+
189
+ function formatRelativeTime(dateStr) {
190
+ if (!dateStr) return ''
191
+ const date = new Date(dateStr)
192
+ if (isNaN(date.getTime())) return ''
193
+ const now = Date.now()
194
+ const diff = now - date.getTime()
195
+ if (diff < 0) return ''
196
+ const mins = Math.floor(diff / 60000)
197
+ if (mins < 1) return 'Just now'
198
+ if (mins < 60) return `${mins}m ago`
199
+ const hours = Math.floor(mins / 60)
200
+ if (hours < 24) return `${hours}h ago`
201
+ const days = Math.floor(hours / 24)
202
+ if (days < 7) return `${days}d ago`
203
+ if (days < 30) return `${Math.floor(days / 7)}w ago`
204
+ return date.toLocaleDateString()
205
+ }
206
+
207
+ /* ─── Flows Dropdown ─── */
208
+
209
+ function FlowsDropdown({ flows, basePath }) {
210
+ if (!flows || flows.length === 0) return null
211
+ return (
212
+ <Menu.Root>
213
+ <Menu.Trigger
214
+ className={css.iconBtn}
215
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
216
+ aria-label="See flows"
217
+ title="See flows"
218
+ >
219
+ <Icon name="flow" size={16} />
220
+ </Menu.Trigger>
221
+ <Menu.Portal>
222
+ <Menu.Positioner className={css.flowsPositioner} side="bottom" align="end" sideOffset={4}>
223
+ <Menu.Popup className={css.flowsPopup}>
224
+ <div className={css.flowsTitle}>Flows</div>
225
+ {flows.map(flow => (
226
+ <Menu.Item
227
+ key={flow.key}
228
+ className={css.flowsItem}
229
+ onClick={(e) => {
230
+ e.preventDefault()
231
+ window.location.href = withBase(basePath, flow.route)
232
+ }}
233
+ >
234
+ {flow.meta?.title || flow.name}
235
+ </Menu.Item>
236
+ ))}
237
+ </Menu.Popup>
238
+ </Menu.Positioner>
239
+ </Menu.Portal>
240
+ </Menu.Root>
241
+ )
242
+ }
243
+
244
+ /* ─── Folder Section ─── */
245
+
246
+ function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
247
+ return (
248
+ <section className={collapsed ? css.folderSectionCollapsed : css.folderSection}>
249
+ <button className={css.folderHeader} onClick={onToggle}>
250
+ <Icon name={collapsed ? 'folder' : 'folder-open'} size={16} className={css.folderIcon} />
251
+ <span className={css.folderName}>{folder.name}</span>
252
+ <span className={css.folderCount}>{folder.items.length}</span>
253
+ <ChevronRightIcon
254
+ size={14}
255
+ className={collapsed ? css.folderChevron : css.folderChevronExpanded}
256
+ />
257
+ </button>
258
+ {!collapsed && (
259
+ <div className={css.grid}>
260
+ {folder.items.map(item => (
261
+ <ArtifactCard
262
+ key={item.id}
263
+ item={item}
264
+ basePath={basePath}
265
+ starred={starred.has(item.id)}
266
+ onToggleStar={onToggleStar}
267
+ />
268
+ ))}
269
+ </div>
270
+ )}
271
+ </section>
272
+ )
273
+ }
274
+
275
+ /* ─── Create Footer ─── */
276
+
277
+ function CreateTip() {
278
+ return (
279
+ <div className={css.createTip}>
280
+ <span className={css.createTipText}>
281
+ Tip: You can ask your AI assistant to create any of these artifacts: <code className={css.createTipCode}>Create a prototype</code>, <code className={css.createTipCode}>Create a canvas</code>, etc
282
+ </span>
283
+ </div>
284
+ )
285
+ }
286
+
287
+ function CreateFooter() {
288
+ return (
289
+ <div className={css.createFooter}>
290
+ <span className={css.createFooterDot} />
291
+ <span className={css.createFooterText}>Only available in dev environment</span>
292
+ </div>
293
+ )
294
+ }
295
+
296
+ /* ─── Create Form ─── */
297
+
298
+ function CreateForm({ type, onBack, onClose, basePath }) {
299
+ const [name, setName] = useState('')
300
+ const [title, setTitle] = useState('')
301
+ const [description, setDescription] = useState('')
302
+ const [url, setUrl] = useState('')
303
+ const [isExternal, setIsExternal] = useState(false)
304
+ const [prototype, setPrototype] = useState('')
305
+ const [prototypes, setPrototypes] = useState([])
306
+ const [error, setError] = useState('')
307
+ const [submitting, setSubmitting] = useState(false)
308
+
309
+ const needsPrototype = type === 'Flow' || type === 'Page'
310
+
311
+ useEffect(() => {
312
+ if (!needsPrototype) return
313
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
314
+ fetch(`${apiBase}/_storyboard/workshop/flows`)
315
+ .then(r => r.ok ? r.json() : null)
316
+ .then(data => {
317
+ if (data?.prototypes) setPrototypes(data.prototypes)
318
+ })
319
+ .catch(() => {})
320
+ }, [needsPrototype, basePath])
321
+
322
+ const handleSubmit = async (e) => {
323
+ e.preventDefault()
324
+ if (!name.trim()) { setError('Name is required'); return }
325
+ if (needsPrototype && !prototype) { setError('Select a prototype'); return }
326
+ setError('')
327
+ setSubmitting(true)
328
+
329
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
330
+ let endpoint, body
331
+ if (type === 'Canvas') {
332
+ endpoint = `${apiBase}/_storyboard/canvas/create`
333
+ body = { name: name.trim(), title: title.trim(), description: description.trim(), grid: true, gridSize: 24 }
334
+ } else if (type === 'Prototype') {
335
+ endpoint = `${apiBase}/_storyboard/workshop/prototypes`
336
+ body = { name: name.trim(), title: title.trim(), description: description.trim() }
337
+ if (isExternal) { body.external = true; body.url = url.trim() }
338
+ } else if (type === 'Flow') {
339
+ endpoint = `${apiBase}/_storyboard/workshop/flows`
340
+ body = { name: name.trim(), title: title.trim(), prototype, description: description.trim() }
341
+ } else if (type === 'Page') {
342
+ endpoint = `${apiBase}/_storyboard/workshop/pages`
343
+ body = { name: name.trim(), prototype }
344
+ } else {
345
+ endpoint = `${apiBase}/_storyboard/canvas/create-story`
346
+ body = { name: name.trim(), location: 'src/components' }
347
+ }
348
+
349
+ try {
350
+ const res = await fetch(endpoint, {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify(body),
354
+ })
355
+ if (!res.ok) {
356
+ const text = await res.text()
357
+ throw new Error(text || `Request failed (${res.status})`)
358
+ }
359
+ const data = await res.json().catch(() => ({}))
360
+ const route = data.route || data.path || `/${name.trim()}`
361
+ window.location.href = withBase(basePath, route)
362
+ } catch (err) {
363
+ setError(err.message)
364
+ setSubmitting(false)
365
+ }
366
+ }
367
+
368
+ const typeLabels = { Canvas: 'Canvas', Prototype: 'Prototype', Component: 'Component', Flow: 'Prototype Flow', Page: 'Prototype Page' }
369
+
370
+ return (
371
+ <form onSubmit={handleSubmit}>
372
+ <button type="button" className={css.createFormBack} onClick={onBack}>
373
+ ← Back
374
+ </button>
375
+ <div className={css.createMenuTitle}>New {typeLabels[type] || type}</div>
376
+
377
+ {needsPrototype && (
378
+ <div className={css.createFormField}>
379
+ <label className={css.createFormLabel}>Prototype *</label>
380
+ <select
381
+ className={css.createFormInput}
382
+ value={prototype}
383
+ onChange={e => setPrototype(e.target.value)}
384
+ >
385
+ <option value="">Select a prototype…</option>
386
+ {prototypes.map(p => (
387
+ <option key={p.name} value={p.name}>{p.name}</option>
388
+ ))}
389
+ </select>
390
+ </div>
391
+ )}
392
+
393
+ <div className={css.createFormField}>
394
+ <label className={css.createFormLabel}>Name *</label>
395
+ <input
396
+ className={css.createFormInput}
397
+ value={name}
398
+ onChange={e => setName(e.target.value)}
399
+ placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
400
+ autoFocus={!needsPrototype}
401
+ />
402
+ </div>
403
+
404
+ {type !== 'Component' && type !== 'Page' && (
405
+ <>
406
+ <div className={css.createFormField}>
407
+ <label className={css.createFormLabel}>Title</label>
408
+ <input
409
+ className={css.createFormInput}
410
+ value={title}
411
+ onChange={e => setTitle(e.target.value)}
412
+ placeholder="Optional display title"
413
+ />
414
+ </div>
415
+ <div className={css.createFormField}>
416
+ <label className={css.createFormLabel}>Description</label>
417
+ <input
418
+ className={css.createFormInput}
419
+ value={description}
420
+ onChange={e => setDescription(e.target.value)}
421
+ placeholder="Optional description"
422
+ />
423
+ </div>
424
+ </>
425
+ )}
426
+
427
+ {type === 'Prototype' && (
428
+ <>
429
+ <div className={css.createFormField}>
430
+ <label className={css.createFormCheckbox}>
431
+ <input
432
+ type="checkbox"
433
+ checked={isExternal}
434
+ onChange={e => setIsExternal(e.target.checked)}
435
+ />
436
+ External prototype
437
+ </label>
438
+ </div>
439
+ {isExternal && (
440
+ <div className={css.createFormField}>
441
+ <label className={css.createFormLabel}>URL</label>
442
+ <input
443
+ className={css.createFormInput}
444
+ value={url}
445
+ onChange={e => setUrl(e.target.value)}
446
+ placeholder="https://example.com"
447
+ />
448
+ </div>
449
+ )}
450
+ </>
451
+ )}
452
+
453
+ {error && <div className={css.createFormError}>{error}</div>}
454
+
455
+ <div className={css.createFormActions}>
456
+ <button type="button" className={css.btnSecondary} onClick={onClose}>Cancel</button>
457
+ <button type="submit" className={css.btnPrimary} disabled={submitting}>
458
+ {submitting ? 'Creating…' : 'Create'}
459
+ </button>
460
+ </div>
461
+
462
+ <CreateFooter />
463
+ </form>
464
+ )
465
+ }
466
+
467
+ /* ─── Create Menu (Dropdown) ─── */
468
+
469
+ function CreateMenu({ onClose, basePath }) {
470
+ const [activeForm, setActiveForm] = useState(null)
471
+ const [showMore, setShowMore] = useState(false)
472
+
473
+ const items = [
474
+ { icon: <Icon name="canvas" size={18} />, title: 'Canvas', desc: 'Interactive board for prototypes, components, and documents' },
475
+ { icon: <Icon name="prototype" size={18} />, title: 'Prototype', desc: 'Interactive page flow' },
476
+ { icon: <Icon name="component" size={18} />, title: 'Component', desc: 'Reusable component' },
477
+ ]
478
+
479
+ const moreItems = [
480
+ { title: 'Prototype Flow', desc: 'A flow data file for a prototype', type: 'Flow' },
481
+ { title: 'Prototype Page', desc: 'A new page inside a prototype', type: 'Page' },
482
+ ]
483
+
484
+ if (activeForm) {
485
+ return (
486
+ <div className={css.createDropdownForm} onKeyDown={e => e.stopPropagation()}>
487
+ <CreateForm
488
+ type={activeForm}
489
+ onBack={() => setActiveForm(null)}
490
+ onClose={onClose}
491
+ basePath={basePath}
492
+ />
493
+ </div>
494
+ )
495
+ }
496
+
497
+ return (
498
+ <>
499
+ <div className={css.createDropdownTitle}>Create new artifact</div>
500
+ <div className={css.createDropdownGrid}>
501
+ {items.map(it => (
502
+ <button key={it.title} className={css.createMenuItem} onClick={() => setActiveForm(it.title)}>
503
+ <div className={css.createMenuIcon}>{it.icon}</div>
504
+ <div>
505
+ <div className={css.createMenuItemTitle}>{it.title}</div>
506
+ <div className={css.createMenuItemDesc}>{it.desc}</div>
507
+ </div>
508
+ </button>
509
+ ))}
510
+ </div>
511
+
512
+ {!showMore ? (
513
+ <button className={css.moreOptionsBtn} onClick={() => setShowMore(true)}>
514
+ More options <ChevronDownIcon size={12} />
515
+ </button>
516
+ ) : (
517
+ <div className={css.moreOptionsSection}>
518
+ {moreItems.map(it => (
519
+ <button key={it.title} className={css.moreOptionItem} onClick={() => setActiveForm(it.type)}>
520
+ <div className={css.moreOptionTitle}>{it.title}</div>
521
+ <div className={css.moreOptionDesc}>{it.desc}</div>
522
+ </button>
523
+ ))}
524
+ </div>
525
+ )}
526
+
527
+ <CreateTip />
528
+ <CreateFooter />
529
+ </>
530
+ )
531
+ }
532
+
533
+ /* ─── PAT Dialog ─── */
534
+
535
+ const COMMENTS_TOKEN_KEY = 'sb-comments-token'
536
+ const REPO_OWNER = 'dfosco'
537
+ const REPO_NAME = 'storyboard'
538
+
539
+ function getRepoInfo() {
540
+ try {
541
+ const cfg = typeof __STORYBOARD_CONFIG__ !== 'undefined' ? __STORYBOARD_CONFIG__ : null
542
+ const repo = cfg?.repository
543
+ if (repo?.owner && repo?.name) return repo
544
+ } catch { /* ignore */ }
545
+ return { owner: REPO_OWNER, name: REPO_NAME }
546
+ }
547
+
548
+ function PATDialog({ open, onClose }) {
549
+ const [tokenValue, setTokenValue] = useState('')
550
+
551
+ if (!open) return null
552
+
553
+ const repo = getRepoInfo()
554
+
555
+ const handleSignIn = () => {
556
+ const trimmed = tokenValue.trim()
557
+ if (!trimmed) return
558
+
559
+ // Store token to localStorage
560
+ try { localStorage.setItem(COMMENTS_TOKEN_KEY, trimmed) } catch { /* ignore */ }
561
+
562
+ // Try the comments auth API if available
563
+ try {
564
+ import('@dfosco/storyboard-core/comments').then(({ setToken }) => {
565
+ setToken(trimmed)
566
+ }).catch(() => {})
567
+ } catch { /* comments module may not be initialized */ }
568
+
569
+ setTokenValue('')
570
+ onClose()
571
+ }
572
+
573
+ const handleKeyDown = (e) => {
574
+ if (e.key === 'Enter') handleSignIn()
575
+ }
576
+
577
+ return (
578
+ <div className={css.createMenuOverlay} onClick={onClose}>
579
+ <div className={css.dialog} onClick={e => e.stopPropagation()}>
580
+ <button className={css.dialogClose} onClick={onClose} aria-label="Close">×</button>
581
+
582
+ <div className={css.dialogTitle}>Sign in for comments</div>
583
+ <div className={css.dialogDesc}>
584
+ Leave comments for other users to see and respond, and react to! Storyboard comments use Discussions as a back-end and require a GitHub PAT to be enabled.
585
+ </div>
586
+
587
+ <hr className={css.dialogSeparator} />
588
+
589
+ <div className={css.tokenCard}>
590
+ <div className={css.tokenCardTitle}>Fine-grained Personal Access Token</div>
591
+ <div className={css.tokenCardRow}>
592
+ <span className={css.tokenCardLabel}>Owner:</span>
593
+ <span className={css.tokenCardValue}>{repo.owner}</span>
594
+ </div>
595
+ <div className={css.tokenCardRow}>
596
+ <span className={css.tokenCardLabel}>Expiration:</span>
597
+ <span className={css.tokenCardValue}><strong>366 days</strong> (recommended)</span>
598
+ </div>
599
+ <div className={css.tokenCardRow}>
600
+ <span className={css.tokenCardLabel}>Repository access:</span>
601
+ <span className={css.tokenCardValue}>Only select repositories &gt; {repo.owner}/{repo.name}</span>
602
+ </div>
603
+ <div className={css.tokenCardRow}>
604
+ <span className={css.tokenCardLabel}>Permissions:</span>
605
+ <span className={css.tokenCardValue}>Repositories &gt; Discussions &gt; Access: Read and Write</span>
606
+ </div>
607
+ </div>
608
+
609
+ <a
610
+ className={css.tokenLink}
611
+ href="https://github.com/settings/personal-access-tokens/new"
612
+ target="_blank"
613
+ rel="noopener noreferrer"
614
+ >
615
+ Create a GitHub Fine-Grained Personal Access Token ↗
616
+ </a>
617
+
618
+ <hr className={css.dialogSeparator} />
619
+
620
+ <label className={css.dialogLabel}>Personal Access Token</label>
621
+ <input
622
+ className={css.dialogInput}
623
+ placeholder="github_pat_… or ghp_…"
624
+ type="password"
625
+ autoFocus
626
+ value={tokenValue}
627
+ onChange={e => setTokenValue(e.target.value)}
628
+ onKeyDown={handleKeyDown}
629
+ />
630
+
631
+ <div className={css.warningBanner}>
632
+ <span className={css.warningIcon}>⚠️</span>
633
+ <span>Comments are an experimental feature and may be unstable.</span>
634
+ </div>
635
+
636
+ <div className={css.dialogActions}>
637
+ <button className={css.btnSecondary} onClick={onClose}>Cancel</button>
638
+ <button className={css.btnPrimary} onClick={handleSignIn}>Sign in</button>
639
+ </div>
640
+ </div>
641
+ </div>
642
+ )
643
+ }
644
+
645
+ /* ─── Nav config ─── */
646
+
647
+ const NAV_ITEMS = [
648
+ { id: 'all', label: 'All artifacts', iconName: 'iconoir/view-grid' },
649
+ { id: 'prototypes', label: 'Prototypes', iconName: 'prototype' },
650
+ { id: 'canvases', label: 'Canvas', iconName: 'canvas' },
651
+ { id: 'components', label: 'Components', iconName: 'component' },
652
+ ]
653
+
654
+ const TAB_FILTERS = ['All', 'Recent', 'Starred']
655
+
656
+ /* ─── Branch Dropdown ─── */
657
+
658
+ function useBranches(basePath) {
659
+ const [branches, setBranches] = useState(() => {
660
+ if (typeof window !== 'undefined' && Array.isArray(window.__SB_BRANCHES__)) {
661
+ return window.__SB_BRANCHES__
662
+ }
663
+ return null
664
+ })
665
+
666
+ const [gitUser, setGitUser] = useState(null)
667
+
668
+ useEffect(() => {
669
+ const apiBase = (basePath || '/').replace(/\/$/, '')
670
+
671
+ // Fetch git user info for "my branches" filtering
672
+ fetch(`${apiBase}/_storyboard/git-user`).then(r => r.ok ? r.json() : null)
673
+ .then(data => { if (data?.name) setGitUser(data.name) })
674
+ .catch(() => {})
675
+
676
+ // Always fetch live branch list from server API
677
+ fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
678
+ .then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
679
+ .catch(() => {})
680
+ }, [])
681
+
682
+ const currentBranch = useMemo(() => {
683
+ const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
684
+ return m ? m[1] : 'main'
685
+ }, [basePath])
686
+
687
+ const branchBasePath = (basePath || '/').replace(/\/branch--[^/]*\/$/, '/')
688
+
689
+ return { branches, currentBranch, branchBasePath, gitUser }
690
+ }
691
+
692
+ function BranchDropdown({ basePath }) {
693
+ const { branches, currentBranch, branchBasePath, gitUser } = useBranches(basePath)
694
+ const [showAll, setShowAll] = useState(false)
695
+ const [switching, setSwitching] = useState(null)
696
+ const [switchError, setSwitchError] = useState(null)
697
+
698
+ if (!branches || branches.length === 0) return null
699
+
700
+ const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000
701
+
702
+ // Split into "my branches" vs others
703
+ const myBranches = gitUser
704
+ ? branches.filter(b => b.author === gitUser || b.branch === currentBranch)
705
+ : branches.filter(b => b.branch === currentBranch)
706
+
707
+ const otherBranches = branches.filter(b => !myBranches.some(m => m.branch === b.branch))
708
+
709
+ // Recent = last 2 weeks (or all if showAll)
710
+ const recentBranches = showAll
711
+ ? [...otherBranches].sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
712
+ : otherBranches
713
+ .filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
714
+ .sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
715
+
716
+ const switchBranch = async (branch) => {
717
+ setSwitching(branch)
718
+ setSwitchError(null)
719
+ const apiBase = (basePath || '/').replace(/\/$/, '')
720
+ try {
721
+ const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify({ branch }),
725
+ })
726
+ const data = await res.json()
727
+ if (res.ok && data.url) {
728
+ window.location.href = data.url
729
+ } else {
730
+ setSwitchError(data.error || 'Failed to switch')
731
+ setSwitching(null)
732
+ }
733
+ } catch (e) {
734
+ setSwitchError(e.message || 'Server not reachable')
735
+ setSwitching(null)
736
+ }
737
+ }
738
+
739
+ return (
740
+ <Menu.Root>
741
+ <Menu.Trigger className={css.branchBtn} disabled={!!switching}>
742
+ <GitBranchIcon size={14} />
743
+ <span className={css.branchBtnText}>{switching ? `Switching to ${switching}…` : currentBranch}</span>
744
+ {!switching && <ChevronDownIcon size={12} />}
745
+ </Menu.Trigger>
746
+ <Menu.Portal>
747
+ <Menu.Positioner className={css.branchPositioner} side="bottom" align="end" sideOffset={4}>
748
+ <Menu.Popup className={css.branchPopup}>
749
+ {myBranches.length > 0 && (
750
+ <>
751
+ <div className={css.branchSectionLabel}>My branches</div>
752
+ {myBranches.map(b => (
753
+ <Menu.Item
754
+ key={b.branch}
755
+ className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
756
+ onClick={() => switchBranch(b.branch)}
757
+ >
758
+ <GitBranchIcon size={12} />
759
+ {b.branch}
760
+ </Menu.Item>
761
+ ))}
762
+ </>
763
+ )}
764
+
765
+ {myBranches.length > 0 && recentBranches.length > 0 && (
766
+ <div className={css.branchSeparator} />
767
+ )}
768
+
769
+ {recentBranches.length > 0 && (
770
+ <>
771
+ <div className={css.branchSectionLabel}>
772
+ {showAll ? 'All branches' : 'Recent branches'}
773
+ </div>
774
+ <Menu.Viewport className={css.branchViewport}>
775
+ {recentBranches.map(b => (
776
+ <Menu.Item
777
+ key={b.branch}
778
+ className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
779
+ onClick={() => switchBranch(b.branch)}
780
+ >
781
+ <GitBranchIcon size={12} />
782
+ {b.branch}
783
+ </Menu.Item>
784
+ ))}
785
+ </Menu.Viewport>
786
+ </>
787
+ )}
788
+
789
+ {!showAll && otherBranches.length > recentBranches.length && (
790
+ <>
791
+ <div className={css.branchSeparator} />
792
+ <button
793
+ className={css.branchShowAll}
794
+ onClick={(e) => { e.stopPropagation(); setShowAll(true) }}
795
+ >
796
+ See all branches ({otherBranches.length})
797
+ </button>
798
+ </>
799
+ )}
800
+ </Menu.Popup>
801
+ </Menu.Positioner>
802
+ </Menu.Portal>
803
+ </Menu.Root>
804
+ )
805
+ }
806
+
807
+ /* ─── Main Component ─── */
808
+
809
+ export default function ViewfinderNew({
810
+ pageModules = {},
811
+ basePath,
812
+ title = 'Storyboard',
813
+ subtitle,
814
+ hideDefaultFlow,
815
+ hideDefaultScene = false,
816
+ }) {
817
+ const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
818
+
819
+ // Build data index from real prototype/canvas/story data
820
+ const knownRoutes = useMemo(() =>
821
+ Object.keys(pageModules)
822
+ .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
823
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
824
+ [pageModules],
825
+ )
826
+
827
+ const prototypeIndex = useMemo(() => buildPrototypeIndex(knownRoutes), [knownRoutes])
828
+
829
+ // Build unified items list from all sources
830
+ const allItems = useMemo(() => {
831
+ const items = []
832
+
833
+ // Prototypes (ungrouped + from folders)
834
+ const addProto = (proto) => {
835
+ // For prototypes with flows, use the first flow's route
836
+ const route = proto.flows?.length > 0
837
+ ? proto.flows[0].route
838
+ : `/${proto.dirName}`
839
+
840
+ items.push({
841
+ id: `proto:${proto.dirName}`,
842
+ name: proto.name,
843
+ type: 'prototype',
844
+ author: proto.author,
845
+ gitAuthor: proto.gitAuthor,
846
+ lastModified: proto.lastModified,
847
+ route,
848
+ isExternal: proto.isExternal,
849
+ externalUrl: proto.externalUrl,
850
+ folder: proto.folder,
851
+ description: proto.description,
852
+ flows: proto.flows || [],
853
+ })
854
+ }
855
+
856
+ for (const proto of prototypeIndex.prototypes || []) addProto(proto)
857
+ for (const folder of prototypeIndex.folders || []) {
858
+ for (const proto of folder.prototypes || []) addProto(proto)
859
+ }
860
+
861
+ // Canvases (ungrouped + from folders)
862
+ const addCanvas = (canvas) => {
863
+ items.push({
864
+ id: `canvas:${canvas.dirName}`,
865
+ name: canvas.name,
866
+ type: 'canvas',
867
+ author: canvas.author,
868
+ gitAuthor: canvas.gitAuthor,
869
+ lastModified: null,
870
+ route: canvas.route,
871
+ isExternal: false,
872
+ externalUrl: null,
873
+ folder: canvas.folder,
874
+ description: canvas.description,
875
+ })
876
+ }
877
+
878
+ for (const canvas of prototypeIndex.canvases || []) addCanvas(canvas)
879
+ for (const folder of prototypeIndex.folders || []) {
880
+ for (const canvas of folder.canvases || []) addCanvas(canvas)
881
+ }
882
+
883
+ // Components (stories)
884
+ const storyNames = listStories()
885
+ for (const name of storyNames) {
886
+ const data = getStoryData(name)
887
+ if (!data) continue
888
+ items.push({
889
+ id: `component:${name}`,
890
+ name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
891
+ type: 'component',
892
+ author: null,
893
+ gitAuthor: null,
894
+ lastModified: null,
895
+ route: data._route || `/components/${name}`,
896
+ isExternal: false,
897
+ externalUrl: null,
898
+ folder: null,
899
+ description: null,
900
+ })
901
+ }
902
+
903
+ return items
904
+ }, [prototypeIndex])
905
+
906
+ const itemMap = useMemo(() => Object.fromEntries(allItems.map(i => [i.id, i])), [allItems])
907
+
908
+ // State
909
+ const [activeNav, setActiveNav] = useState('all')
910
+ const [activeTab, setActiveTab] = useState('All')
911
+ const [showCreate, setShowCreate] = useState(false)
912
+ const [showPAT, setShowPAT] = useState(false)
913
+ const [sidebarOpen, setSidebarOpen] = useState(false)
914
+ const [groupByFolders, setGroupByFolders] = useState(() => {
915
+ try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
916
+ })
917
+ const [collapsedFolders, setCollapsedFolders] = useState(new Set())
918
+ const { starred, toggle: toggleStar } = useStarred()
919
+ const recentIds = useRecent()
920
+
921
+ // Filter by nav category
922
+ const navFiltered = useMemo(() => {
923
+ if (activeNav === 'all') return allItems
924
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
925
+ return allItems.filter(i => i.type === typeMap[activeNav])
926
+ }, [allItems, activeNav])
927
+
928
+ // Filter by tab
929
+ const items = useMemo(() => {
930
+ if (activeTab === 'Recent') {
931
+ const ordered = recentIds.map(id => itemMap[id]).filter(Boolean)
932
+ if (activeNav !== 'all') {
933
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
934
+ return ordered.filter(i => i.type === typeMap[activeNav])
935
+ }
936
+ return ordered
937
+ }
938
+ const base = activeTab === 'Starred'
939
+ ? navFiltered.filter(i => starred.has(i.id))
940
+ : navFiltered
941
+ return [...base].sort((a, b) => {
942
+ const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
943
+ const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
944
+ return bTime - aTime
945
+ })
946
+ }, [activeTab, activeNav, navFiltered, recentIds, itemMap, starred])
947
+
948
+ // Grouped items for folder view
949
+ const grouped = useMemo(() => {
950
+ if (!groupByFolders) return null
951
+ const folderItems = {}
952
+ const ungrouped = []
953
+ for (const item of items) {
954
+ if (item.folder) {
955
+ if (!folderItems[item.folder]) folderItems[item.folder] = []
956
+ folderItems[item.folder].push(item)
957
+ } else {
958
+ ungrouped.push(item)
959
+ }
960
+ }
961
+ const folderMeta = {}
962
+ for (const f of prototypeIndex.folders || []) folderMeta[f.dirName] = f
963
+ const folders = Object.entries(folderItems).map(([dirName, fItems]) => ({
964
+ dirName,
965
+ name: folderMeta[dirName]?.name || dirName,
966
+ items: fItems,
967
+ }))
968
+ folders.sort((a, b) => {
969
+ const aMax = Math.max(0, ...a.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
970
+ const bMax = Math.max(0, ...b.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
971
+ return bMax - aMax
972
+ })
973
+ return { ungrouped, folders }
974
+ }, [items, groupByFolders, prototypeIndex])
975
+
976
+ const toggleGrouping = useCallback(() => {
977
+ setGroupByFolders(prev => {
978
+ const next = !prev
979
+ try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch {}
980
+ return next
981
+ })
982
+ }, [])
983
+
984
+ const toggleFolder = useCallback((dirName) => {
985
+ setCollapsedFolders(prev => {
986
+ const next = new Set(prev)
987
+ if (next.has(dirName)) next.delete(dirName)
988
+ else next.add(dirName)
989
+ return next
990
+ })
991
+ }, [])
992
+
993
+ // Counts
994
+ const counts = useMemo(() => ({
995
+ all: allItems.length,
996
+ prototypes: allItems.filter(i => i.type === 'prototype').length,
997
+ canvases: allItems.filter(i => i.type === 'canvas').length,
998
+ components: allItems.filter(i => i.type === 'component').length,
999
+ }), [allItems])
1000
+
1001
+ // Starred items for sidebar
1002
+ const starredItems = useMemo(() => allItems.filter(i => starred.has(i.id)), [allItems, starred])
1003
+
1004
+ const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
1005
+
1006
+ return (
1007
+ <div className={css.layout}>
1008
+ {/* ─── Full-width Header ─── */}
1009
+ <header className={css.topBar}>
1010
+ <div className={css.topBarLeft}>
1011
+ <button
1012
+ className={css.hamburgerBtn}
1013
+ onClick={() => setSidebarOpen(prev => !prev)}
1014
+ aria-label="Toggle menu"
1015
+ >
1016
+ {sidebarOpen ? <XIcon size={18} /> : <ThreeBarsIcon size={18} />}
1017
+ </button>
1018
+ <div className={`${css.logo} smooth-corners`}><Icon name="iconoir/key-command" size={22} color="#fff" /></div>
1019
+ <div>
1020
+ <div className={css.appName}>{title}</div>
1021
+ {subtitle && <div className={css.appSubtitle}>{subtitle}</div>}
1022
+ </div>
1023
+ </div>
1024
+ <div className={css.topActions}>
1025
+ <BranchDropdown basePath={basePath} />
1026
+ <Menu.Root open={showCreate} onOpenChange={setShowCreate}>
1027
+ <Menu.Trigger className={css.createBtn}>
1028
+ + Create
1029
+ </Menu.Trigger>
1030
+ <Menu.Portal>
1031
+ <Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
1032
+ <Menu.Popup className={css.createDropdown}>
1033
+ <CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
1034
+ </Menu.Popup>
1035
+ </Menu.Positioner>
1036
+ </Menu.Portal>
1037
+ </Menu.Root>
1038
+ </div>
1039
+ </header>
1040
+
1041
+ {/* ─── Body: Sidebar + Content ─── */}
1042
+ <div className={css.body}>
1043
+ {/* ─── Sidebar ─── */}
1044
+ <aside className={`${css.sidebar}${sidebarOpen ? ` ${css.sidebarOpen}` : ''}`}>
1045
+ <nav className={css.navSection}>
1046
+ {NAV_ITEMS.map(nav => (
1047
+ <button
1048
+ key={nav.id}
1049
+ className={activeNav === nav.id ? css.navItemActive : css.navItem}
1050
+ onClick={() => { setActiveNav(nav.id); setSidebarOpen(false) }}
1051
+ >
1052
+ <span className={css.navIcon}><Icon name={nav.iconName} size={16} /></span>
1053
+ {nav.label}
1054
+ <span className={css.navCount}>{counts[nav.id]}</span>
1055
+ </button>
1056
+ ))}
1057
+ </nav>
1058
+
1059
+ <div className={css.separator} />
1060
+
1061
+ <div className={css.sectionLabel}>Starred</div>
1062
+ {starredItems.length === 0 && (
1063
+ <div className={css.starredEmpty}>Star items to pin them here</div>
1064
+ )}
1065
+ {starredItems.map(s => (
1066
+ <a
1067
+ key={s.id}
1068
+ className={css.starredItem}
1069
+ href={s.isExternal ? s.externalUrl : withBase(basePath, s.route)}
1070
+ {...(s.isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
1071
+ onClick={() => trackRecent(s.id)}
1072
+ >
1073
+ <span className={css.starredIcon}>{getTypeIcon(s.type)}</span>
1074
+ {s.name}
1075
+ </a>
1076
+ ))}
1077
+
1078
+ {/* User profile / login */}
1079
+ <div className={css.sidebarFooter}>
1080
+ <button className={css.loginBtn} onClick={() => setShowPAT(true)}>
1081
+ <span className={css.avatar}><MarkGithubIcon size={16} /></span>
1082
+ <div>
1083
+ <div className={css.userName}>Sign in</div>
1084
+ <div className={css.userSub}>Connect with GitHub</div>
1085
+ </div>
1086
+ </button>
1087
+ </div>
1088
+ </aside>
1089
+
1090
+ {/* ─── Main ─── */}
1091
+ <main className={css.main}>
1092
+ {/* Tabs */}
1093
+ <div className={css.tabs}>
1094
+ {TAB_FILTERS.map(t => (
1095
+ <button
1096
+ key={t}
1097
+ className={activeTab === t ? css.tabActive : css.tab}
1098
+ onClick={() => setActiveTab(t)}
1099
+ >
1100
+ {t}
1101
+ </button>
1102
+ ))}
1103
+ <label className={css.groupByFolders}>
1104
+ <input
1105
+ type="checkbox"
1106
+ className={css.groupByFoldersCheckbox}
1107
+ checked={groupByFolders}
1108
+ onChange={toggleGrouping}
1109
+ />
1110
+ Group by folders
1111
+ </label>
1112
+ </div>
1113
+
1114
+ {/* Grid */}
1115
+ <div className={css.content}>
1116
+ {items.length === 0 ? (
1117
+ <div className={css.emptyState}>
1118
+ {activeTab === 'Recent' && 'No recently opened items yet.'}
1119
+ {activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
1120
+ {activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
1121
+ </div>
1122
+ ) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
1123
+ <>
1124
+ {grouped.folders.map(folder => (
1125
+ <FolderSection
1126
+ key={folder.dirName}
1127
+ folder={folder}
1128
+ collapsed={collapsedFolders.has(folder.dirName)}
1129
+ onToggle={() => toggleFolder(folder.dirName)}
1130
+ basePath={basePath}
1131
+ starred={starred}
1132
+ onToggleStar={toggleStar}
1133
+ />
1134
+ ))}
1135
+ {grouped.ungrouped.length > 0 && (
1136
+ <div className={css.grid}>
1137
+ {grouped.ungrouped.map(item => (
1138
+ <ArtifactCard
1139
+ key={item.id}
1140
+ item={item}
1141
+ basePath={basePath}
1142
+ starred={starred.has(item.id)}
1143
+ onToggleStar={toggleStar}
1144
+ />
1145
+ ))}
1146
+ </div>
1147
+ )}
1148
+ </>
1149
+ ) : (
1150
+ <div className={css.grid}>
1151
+ {items.map(item => (
1152
+ <ArtifactCard
1153
+ key={item.id}
1154
+ item={item}
1155
+ basePath={basePath}
1156
+ starred={starred.has(item.id)}
1157
+ onToggleStar={toggleStar}
1158
+ />
1159
+ ))}
1160
+ </div>
1161
+ )}
1162
+ </div>
1163
+ </main>
1164
+ </div>
1165
+
1166
+ {/* Modals */}
1167
+ <PATDialog open={showPAT} onClose={() => setShowPAT(false)} />
1168
+ </div>
1169
+ )
1170
+ }