@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +375 -57
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -1,72 +1,1045 @@
1
-
2
- import { useRef, useEffect, useMemo } from 'react'
3
-
4
1
  /**
5
- * Viewfinder — thin React wrapper around the Svelte Viewfinder component.
6
- *
7
- * Mounts the core Svelte Viewfinder into a container div and manages
8
- * its lifecycle via React's useEffect.
2
+ * Viewfinder — SaaS-style homescreen for Storyboard.
9
3
  *
10
- * @param {Object} props
11
- * @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored — data comes from core)
12
- * @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
13
- * @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
14
- * @param {string} [props.basePath] - Base URL path
15
- * @param {string} [props.title] - Header title
16
- * @param {string} [props.subtitle] - Optional subtitle
17
- * @param {boolean} [props.showThumbnails] - Show thumbnail previews
18
- * @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
4
+ * Replaces the old list-based Viewfinder with a sidebar + grid layout.
5
+ * Wired to real data from buildPrototypeIndex and listStories.
19
6
  */
20
- export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
21
- const containerRef = useRef(null)
22
- const handleRef = useRef(null)
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, PlusIcon, 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 './Viewfinder.module.css'
23
13
 
24
- const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
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>
25
461
 
26
- const knownRoutes = useMemo(() => Object.keys(pageModules)
27
- .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
28
- .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
29
- [pageModules])
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
+ /* ─── Nav config ─── */
536
+
537
+ const NAV_ITEMS = [
538
+ { id: 'all', label: 'All artifacts', iconName: 'iconoir/view-grid' },
539
+ { id: 'prototypes', label: 'Prototypes', iconName: 'prototype' },
540
+ { id: 'canvases', label: 'Canvas', iconName: 'canvas' },
541
+ { id: 'components', label: 'Components', iconName: 'component' },
542
+ ]
543
+
544
+ const TAB_FILTERS = ['All', 'Recent', 'Starred']
545
+
546
+ /* ─── Branch Dropdown ─── */
547
+
548
+ function useBranches(basePath) {
549
+ const [branches, setBranches] = useState(() => {
550
+ if (typeof window !== 'undefined' && Array.isArray(window.__SB_BRANCHES__)) {
551
+ return window.__SB_BRANCHES__
552
+ }
553
+ return null
554
+ })
555
+
556
+ const [gitUser, setGitUser] = useState(null)
30
557
 
31
558
  useEffect(() => {
32
- if (!containerRef.current) return
33
-
34
- let cancelled = false
35
-
36
- import('@dfosco/storyboard-core/ui-runtime').then(({ mountViewfinder, unmountViewfinder }) => {
37
- if (cancelled) return
38
- // Ensure clean state for re-mounts
39
- unmountViewfinder()
40
- handleRef.current = mountViewfinder(containerRef.current, {
41
- title,
42
- subtitle,
43
- basePath,
44
- knownRoutes,
45
- showThumbnails,
46
- hideDefaultFlow: shouldHideDefault,
559
+ const apiBase = (basePath || '/').replace(/\/$/, '')
560
+
561
+ // Fetch git user info for "my branches" filtering
562
+ fetch(`${apiBase}/_storyboard/git-user`).then(r => r.ok ? r.json() : null)
563
+ .then(data => { if (data?.name) setGitUser(data.name) })
564
+ .catch(() => {})
565
+
566
+ // Always fetch live branch list from server API
567
+ fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
568
+ .then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
569
+ .catch(() => {})
570
+ }, [])
571
+
572
+ const currentBranch = useMemo(() => {
573
+ const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
574
+ return m ? m[1] : 'main'
575
+ }, [basePath])
576
+
577
+ const branchBasePath = (basePath || '/').replace(/\/branch--[^/]*\/$/, '/')
578
+
579
+ return { branches, currentBranch, branchBasePath, gitUser }
580
+ }
581
+
582
+ function BranchDropdown({ basePath }) {
583
+ // Dev: hide dropdown — use CLI to switch branches
584
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
585
+ if (isLocalDev) return null
586
+
587
+ const { branches, currentBranch, branchBasePath, gitUser } = useBranches(basePath)
588
+ const [showAll, setShowAll] = useState(false)
589
+ const [switching, setSwitching] = useState(null)
590
+ const [switchError, setSwitchError] = useState(null)
591
+
592
+ if (!branches || branches.length === 0) return null
593
+
594
+ const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000
595
+
596
+ // Split into "my branches" vs others
597
+ const myBranches = gitUser
598
+ ? branches.filter(b => b.author === gitUser || b.branch === currentBranch)
599
+ : branches.filter(b => b.branch === currentBranch)
600
+
601
+ const otherBranches = branches.filter(b => !myBranches.some(m => m.branch === b.branch))
602
+
603
+ // Recent = last 2 weeks (or all if showAll)
604
+ const recentBranches = showAll
605
+ ? [...otherBranches].sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
606
+ : otherBranches
607
+ .filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
608
+ .sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
609
+
610
+ const switchBranch = (branch) => {
611
+ setSwitching(branch)
612
+ const target = branches?.find(b => b.branch === branch)
613
+ window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
614
+ }
615
+
616
+ return (
617
+ <Menu.Root>
618
+ <Menu.Trigger className={css.branchBtn} disabled={!!switching}>
619
+ <GitBranchIcon size={14} />
620
+ <span className={css.branchBtnText}>{switching ? `Switching to ${switching}…` : currentBranch}</span>
621
+ {!switching && <ChevronDownIcon size={12} />}
622
+ </Menu.Trigger>
623
+ <Menu.Portal>
624
+ <Menu.Positioner className={css.branchPositioner} side="bottom" align="end" sideOffset={4}>
625
+ <Menu.Popup className={css.branchPopup}>
626
+ {myBranches.length > 0 && (
627
+ <>
628
+ <div className={css.branchSectionLabel}>My branches</div>
629
+ {myBranches.map(b => (
630
+ <Menu.Item
631
+ key={b.branch}
632
+ className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
633
+ onClick={() => switchBranch(b.branch)}
634
+ >
635
+ <GitBranchIcon size={12} />
636
+ {b.branch}
637
+ </Menu.Item>
638
+ ))}
639
+ </>
640
+ )}
641
+
642
+ {myBranches.length > 0 && recentBranches.length > 0 && (
643
+ <div className={css.branchSeparator} />
644
+ )}
645
+
646
+ {recentBranches.length > 0 && (
647
+ <>
648
+ <div className={css.branchSectionLabel}>
649
+ {showAll ? 'All branches' : 'Recent branches'}
650
+ </div>
651
+ <Menu.Viewport className={css.branchViewport}>
652
+ {recentBranches.map(b => (
653
+ <Menu.Item
654
+ key={b.branch}
655
+ className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
656
+ onClick={() => switchBranch(b.branch)}
657
+ >
658
+ <GitBranchIcon size={12} />
659
+ {b.branch}
660
+ </Menu.Item>
661
+ ))}
662
+ </Menu.Viewport>
663
+ </>
664
+ )}
665
+
666
+ {!showAll && otherBranches.length > recentBranches.length && (
667
+ <>
668
+ <div className={css.branchSeparator} />
669
+ <button
670
+ className={css.branchShowAll}
671
+ onClick={(e) => { e.stopPropagation(); setShowAll(true) }}
672
+ >
673
+ See all branches ({otherBranches.length})
674
+ </button>
675
+ </>
676
+ )}
677
+ </Menu.Popup>
678
+ </Menu.Positioner>
679
+ </Menu.Portal>
680
+ </Menu.Root>
681
+ )
682
+ }
683
+
684
+ /* ─── Main Component ─── */
685
+
686
+ export default function Viewfinder({
687
+ pageModules = {},
688
+ basePath,
689
+ title = 'Storyboard',
690
+ subtitle,
691
+ hideDefaultFlow,
692
+ hideDefaultScene = false,
693
+ }) {
694
+ const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
695
+
696
+ // Build data index from real prototype/canvas/story data
697
+ const knownRoutes = useMemo(() =>
698
+ Object.keys(pageModules)
699
+ .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
700
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
701
+ [pageModules],
702
+ )
703
+
704
+ const prototypeIndex = useMemo(() => buildPrototypeIndex(knownRoutes), [knownRoutes])
705
+
706
+ // Build unified items list from all sources
707
+ const allItems = useMemo(() => {
708
+ const items = []
709
+
710
+ // Prototypes (ungrouped + from folders)
711
+ const addProto = (proto) => {
712
+ // For prototypes with flows, use the first flow's route
713
+ const route = proto.flows?.length > 0
714
+ ? proto.flows[0].route
715
+ : `/${proto.dirName}`
716
+
717
+ items.push({
718
+ id: `proto:${proto.dirName}`,
719
+ name: proto.name,
720
+ type: 'prototype',
721
+ author: proto.author,
722
+ gitAuthor: proto.gitAuthor,
723
+ lastModified: proto.lastModified,
724
+ route,
725
+ isExternal: proto.isExternal,
726
+ externalUrl: proto.externalUrl,
727
+ folder: proto.folder,
728
+ description: proto.description,
729
+ flows: proto.flows || [],
47
730
  })
48
- // Wait for styles to be fully loaded before revealing
49
- handleRef.current.ready.then(() => {
50
- requestAnimationFrame(() => {
51
- if (containerRef.current) containerRef.current.style.opacity = '1'
52
- })
731
+ }
732
+
733
+ for (const proto of prototypeIndex.prototypes || []) addProto(proto)
734
+ for (const folder of prototypeIndex.folders || []) {
735
+ for (const proto of folder.prototypes || []) addProto(proto)
736
+ }
737
+
738
+ // Canvases (ungrouped + from folders)
739
+ const addCanvas = (canvas) => {
740
+ items.push({
741
+ id: `canvas:${canvas.dirName}`,
742
+ name: canvas.name,
743
+ type: 'canvas',
744
+ author: canvas.author,
745
+ gitAuthor: canvas.gitAuthor,
746
+ lastModified: null,
747
+ route: canvas.route,
748
+ isExternal: false,
749
+ externalUrl: null,
750
+ folder: canvas.folder,
751
+ description: canvas.description,
53
752
  })
753
+ }
754
+
755
+ for (const canvas of prototypeIndex.canvases || []) addCanvas(canvas)
756
+ for (const folder of prototypeIndex.folders || []) {
757
+ for (const canvas of folder.canvases || []) addCanvas(canvas)
758
+ }
759
+
760
+ // Components (stories)
761
+ const storyNames = listStories()
762
+ for (const name of storyNames) {
763
+ const data = getStoryData(name)
764
+ if (!data) continue
765
+ items.push({
766
+ id: `component:${name}`,
767
+ name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
768
+ type: 'component',
769
+ author: null,
770
+ gitAuthor: null,
771
+ lastModified: null,
772
+ route: data._route || `/components/${name}`,
773
+ isExternal: false,
774
+ externalUrl: null,
775
+ folder: null,
776
+ description: null,
777
+ })
778
+ }
779
+
780
+ return items
781
+ }, [prototypeIndex])
782
+
783
+ const itemMap = useMemo(() => Object.fromEntries(allItems.map(i => [i.id, i])), [allItems])
784
+
785
+ // State
786
+ const [activeNav, setActiveNav] = useState('all')
787
+ const [activeTab, setActiveTab] = useState('All')
788
+ const [showCreate, setShowCreate] = useState(false)
789
+ const [sidebarOpen, setSidebarOpen] = useState(false)
790
+ const [groupByFolders, setGroupByFolders] = useState(() => {
791
+ try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
792
+ })
793
+ const [collapsedFolders, setCollapsedFolders] = useState(new Set())
794
+ const { starred, toggle: toggleStar } = useStarred()
795
+ const recentIds = useRecent()
796
+
797
+ // Filter by nav category
798
+ const navFiltered = useMemo(() => {
799
+ if (activeNav === 'all') return allItems
800
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
801
+ return allItems.filter(i => i.type === typeMap[activeNav])
802
+ }, [allItems, activeNav])
803
+
804
+ // Filter by tab
805
+ const items = useMemo(() => {
806
+ if (activeTab === 'Recent') {
807
+ const ordered = recentIds.map(id => itemMap[id]).filter(Boolean)
808
+ if (activeNav !== 'all') {
809
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
810
+ return ordered.filter(i => i.type === typeMap[activeNav])
811
+ }
812
+ return ordered
813
+ }
814
+ const base = activeTab === 'Starred'
815
+ ? navFiltered.filter(i => starred.has(i.id))
816
+ : navFiltered
817
+ return [...base].sort((a, b) => {
818
+ const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
819
+ const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
820
+ return bTime - aTime
54
821
  })
822
+ }, [activeTab, activeNav, navFiltered, recentIds, itemMap, starred])
55
823
 
56
- return () => {
57
- cancelled = true
58
- if (handleRef.current) {
59
- handleRef.current.destroy()
60
- handleRef.current = null
824
+ // Grouped items for folder view
825
+ const grouped = useMemo(() => {
826
+ if (!groupByFolders) return null
827
+ const folderItems = {}
828
+ const ungrouped = []
829
+ for (const item of items) {
830
+ if (item.folder) {
831
+ if (!folderItems[item.folder]) folderItems[item.folder] = []
832
+ folderItems[item.folder].push(item)
833
+ } else {
834
+ ungrouped.push(item)
61
835
  }
62
836
  }
63
- }, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
837
+ const folderMeta = {}
838
+ for (const f of prototypeIndex.folders || []) folderMeta[f.dirName] = f
839
+ const folders = Object.entries(folderItems).map(([dirName, fItems]) => ({
840
+ dirName,
841
+ name: folderMeta[dirName]?.name || dirName,
842
+ items: fItems,
843
+ }))
844
+ folders.sort((a, b) => {
845
+ const aMax = Math.max(0, ...a.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
846
+ const bMax = Math.max(0, ...b.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
847
+ return bMax - aMax
848
+ })
849
+ return { ungrouped, folders }
850
+ }, [items, groupByFolders, prototypeIndex])
64
851
 
65
- return <div ref={containerRef} style={{
66
- minHeight: '100vh',
67
- background: 'var(--bgColor-default, #0d1117)',
68
- opacity: 0,
69
- transition: 'opacity 0.15s ease',
70
- }} />
71
- }
852
+ const toggleGrouping = useCallback(() => {
853
+ setGroupByFolders(prev => {
854
+ const next = !prev
855
+ try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch {}
856
+ return next
857
+ })
858
+ }, [])
859
+
860
+ const toggleFolder = useCallback((dirName) => {
861
+ setCollapsedFolders(prev => {
862
+ const next = new Set(prev)
863
+ if (next.has(dirName)) next.delete(dirName)
864
+ else next.add(dirName)
865
+ return next
866
+ })
867
+ }, [])
868
+
869
+ // Counts
870
+ const counts = useMemo(() => ({
871
+ all: allItems.length,
872
+ prototypes: allItems.filter(i => i.type === 'prototype').length,
873
+ canvases: allItems.filter(i => i.type === 'canvas').length,
874
+ components: allItems.filter(i => i.type === 'component').length,
875
+ }), [allItems])
72
876
 
877
+ // Starred items for sidebar
878
+ const starredItems = useMemo(() => allItems.filter(i => starred.has(i.id)), [allItems, starred])
879
+
880
+ const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
881
+
882
+ return (
883
+ <div className={css.layout}>
884
+ {/* ─── Full-width Header ─── */}
885
+ <header className={css.topBar}>
886
+ <div className={css.topBarLeft}>
887
+ <button
888
+ className={css.hamburgerBtn}
889
+ onClick={() => setSidebarOpen(prev => !prev)}
890
+ aria-label="Toggle menu"
891
+ >
892
+ {sidebarOpen ? <XIcon size={18} /> : <ThreeBarsIcon size={18} />}
893
+ </button>
894
+ <div className={`${css.logo} smooth-corners`}><Icon name="iconoir/key-command" size={22} color="#fff" /></div>
895
+ <div>
896
+ <div className={css.appName}>{title}</div>
897
+ {subtitle && <div className={css.appSubtitle}>{subtitle}</div>}
898
+ </div>
899
+ </div>
900
+ <div className={css.topActions}>
901
+ <BranchDropdown basePath={basePath} />
902
+ <Menu.Root open={showCreate} onOpenChange={setShowCreate}>
903
+ <Menu.Trigger className={css.createBtn}>
904
+ <PlusIcon size={14} /> Create
905
+ </Menu.Trigger>
906
+ <Menu.Portal>
907
+ <Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
908
+ <Menu.Popup className={css.createDropdown}>
909
+ <CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
910
+ </Menu.Popup>
911
+ </Menu.Positioner>
912
+ </Menu.Portal>
913
+ </Menu.Root>
914
+ </div>
915
+ </header>
916
+
917
+ {/* ─── Body: Sidebar + Content ─── */}
918
+ <div className={css.body}>
919
+ {/* ─── Sidebar ─── */}
920
+ <aside className={`${css.sidebar}${sidebarOpen ? ` ${css.sidebarOpen}` : ''}`}>
921
+ <div className={css.sidebarContent}>
922
+ <nav className={css.navSection}>
923
+ {NAV_ITEMS.map(nav => (
924
+ <button
925
+ key={nav.id}
926
+ className={activeNav === nav.id ? css.navItemActive : css.navItem}
927
+ onClick={() => { setActiveNav(nav.id); setSidebarOpen(false) }}
928
+ >
929
+ <span className={css.navIcon}><Icon name={nav.iconName} size={16} /></span>
930
+ {nav.label}
931
+ <span className={css.navCount}>{counts[nav.id]}</span>
932
+ </button>
933
+ ))}
934
+ </nav>
935
+
936
+ <div className={css.separator} />
937
+
938
+ <div className={css.sectionLabel}>Starred</div>
939
+ {starredItems.length === 0 && (
940
+ <div className={css.starredEmpty}>Star items to pin them here</div>
941
+ )}
942
+ {starredItems.map(s => (
943
+ <a
944
+ key={s.id}
945
+ className={css.starredItem}
946
+ href={s.isExternal ? s.externalUrl : withBase(basePath, s.route)}
947
+ {...(s.isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
948
+ onClick={() => trackRecent(s.id)}
949
+ >
950
+ <span className={css.starredIcon}>{getTypeIcon(s.type)}</span>
951
+ {s.name}
952
+ </a>
953
+ ))}
954
+ </div>
955
+
956
+ {/* User profile / login */}
957
+ <div className={css.sidebarFooter}>
958
+ <button className={css.loginBtn} onClick={() => document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))}>
959
+ <span className={css.avatar}><MarkGithubIcon size={16} /></span>
960
+ <div>
961
+ <div className={css.userName}>Sign in</div>
962
+ <div className={css.userSub}>Connect with GitHub</div>
963
+ </div>
964
+ </button>
965
+ </div>
966
+ </aside>
967
+
968
+ {/* ─── Main ─── */}
969
+ <main className={css.main}>
970
+ {/* Tabs */}
971
+ <div className={css.tabs}>
972
+ {TAB_FILTERS.map(t => (
973
+ <button
974
+ key={t}
975
+ className={activeTab === t ? css.tabActive : css.tab}
976
+ onClick={() => setActiveTab(t)}
977
+ >
978
+ {t}
979
+ </button>
980
+ ))}
981
+ <label className={css.groupByFolders}>
982
+ <input
983
+ type="checkbox"
984
+ className={css.groupByFoldersCheckbox}
985
+ checked={groupByFolders}
986
+ onChange={toggleGrouping}
987
+ />
988
+ Group by folders
989
+ </label>
990
+ </div>
991
+
992
+ {/* Grid */}
993
+ <div className={css.content}>
994
+ {items.length === 0 ? (
995
+ <div className={css.emptyState}>
996
+ {activeTab === 'Recent' && 'No recently opened items yet.'}
997
+ {activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
998
+ {activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
999
+ </div>
1000
+ ) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
1001
+ <>
1002
+ {grouped.folders.map(folder => (
1003
+ <FolderSection
1004
+ key={folder.dirName}
1005
+ folder={folder}
1006
+ collapsed={collapsedFolders.has(folder.dirName)}
1007
+ onToggle={() => toggleFolder(folder.dirName)}
1008
+ basePath={basePath}
1009
+ starred={starred}
1010
+ onToggleStar={toggleStar}
1011
+ />
1012
+ ))}
1013
+ {grouped.ungrouped.length > 0 && (
1014
+ <div className={css.grid}>
1015
+ {grouped.ungrouped.map(item => (
1016
+ <ArtifactCard
1017
+ key={item.id}
1018
+ item={item}
1019
+ basePath={basePath}
1020
+ starred={starred.has(item.id)}
1021
+ onToggleStar={toggleStar}
1022
+ />
1023
+ ))}
1024
+ </div>
1025
+ )}
1026
+ </>
1027
+ ) : (
1028
+ <div className={css.grid}>
1029
+ {items.map(item => (
1030
+ <ArtifactCard
1031
+ key={item.id}
1032
+ item={item}
1033
+ basePath={basePath}
1034
+ starred={starred.has(item.id)}
1035
+ onToggleStar={toggleStar}
1036
+ />
1037
+ ))}
1038
+ </div>
1039
+ )}
1040
+ </div>
1041
+ </main>
1042
+ </div>
1043
+ </div>
1044
+ )
1045
+ }