@dfosco/storyboard-react 4.0.0-beta.36 → 4.0.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/Icon.jsx +179 -0
- package/src/ViewfinderNew.jsx +1172 -0
- package/src/ViewfinderNew.module.css +1773 -0
- package/src/canvas/CanvasPage.jsx +14 -0
- package/src/canvas/widgets/LinkPreview.jsx +74 -10
- package/src/canvas/widgets/MarkdownBlock.module.css +2 -2
- package/src/canvas/widgets/PrototypeEmbed.jsx +1 -1
- package/src/canvas/widgets/StoryWidget.jsx +1 -1
- package/src/canvas/widgets/StoryWidget.module.css +3 -3
- package/src/index.js +1 -1
- package/src/vite/data-plugin.js +24 -0
- package/src/Viewfinder.jsx +0 -72
- package/src/Viewfinder.module.css +0 -235
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +0 -164
|
@@ -0,0 +1,1172 @@
|
|
|
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 > {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 > Discussions > 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
|
+
// If no branches from window global, fetch from server API
|
|
677
|
+
if (!branches) {
|
|
678
|
+
fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
|
|
679
|
+
.then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
|
|
680
|
+
.catch(() => {})
|
|
681
|
+
}
|
|
682
|
+
}, [])
|
|
683
|
+
|
|
684
|
+
const currentBranch = useMemo(() => {
|
|
685
|
+
const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
|
|
686
|
+
return m ? m[1] : 'main'
|
|
687
|
+
}, [basePath])
|
|
688
|
+
|
|
689
|
+
const branchBasePath = (basePath || '/').replace(/\/branch--[^/]*\/$/, '/')
|
|
690
|
+
|
|
691
|
+
return { branches, currentBranch, branchBasePath, gitUser }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function BranchDropdown({ basePath }) {
|
|
695
|
+
const { branches, currentBranch, branchBasePath, gitUser } = useBranches(basePath)
|
|
696
|
+
const [showAll, setShowAll] = useState(false)
|
|
697
|
+
const [switching, setSwitching] = useState(null)
|
|
698
|
+
const [switchError, setSwitchError] = useState(null)
|
|
699
|
+
|
|
700
|
+
if (!branches || branches.length === 0) return null
|
|
701
|
+
|
|
702
|
+
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000
|
|
703
|
+
|
|
704
|
+
// Split into "my branches" vs others
|
|
705
|
+
const myBranches = gitUser
|
|
706
|
+
? branches.filter(b => b.author === gitUser || b.branch === currentBranch)
|
|
707
|
+
: branches.filter(b => b.branch === currentBranch)
|
|
708
|
+
|
|
709
|
+
const otherBranches = branches.filter(b => !myBranches.some(m => m.branch === b.branch))
|
|
710
|
+
|
|
711
|
+
// Recent = last 2 weeks (or all if showAll)
|
|
712
|
+
const recentBranches = showAll
|
|
713
|
+
? [...otherBranches].sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
|
|
714
|
+
: otherBranches
|
|
715
|
+
.filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
|
|
716
|
+
.sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
|
|
717
|
+
|
|
718
|
+
const switchBranch = async (branch) => {
|
|
719
|
+
setSwitching(branch)
|
|
720
|
+
setSwitchError(null)
|
|
721
|
+
const apiBase = (basePath || '/').replace(/\/$/, '')
|
|
722
|
+
try {
|
|
723
|
+
const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
|
|
724
|
+
method: 'POST',
|
|
725
|
+
headers: { 'Content-Type': 'application/json' },
|
|
726
|
+
body: JSON.stringify({ branch }),
|
|
727
|
+
})
|
|
728
|
+
const data = await res.json()
|
|
729
|
+
if (res.ok && data.url) {
|
|
730
|
+
window.location.href = data.url
|
|
731
|
+
} else {
|
|
732
|
+
setSwitchError(data.error || 'Failed to switch')
|
|
733
|
+
setSwitching(null)
|
|
734
|
+
}
|
|
735
|
+
} catch (e) {
|
|
736
|
+
setSwitchError(e.message || 'Server not reachable')
|
|
737
|
+
setSwitching(null)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return (
|
|
742
|
+
<Menu.Root>
|
|
743
|
+
<Menu.Trigger className={css.branchBtn} disabled={!!switching}>
|
|
744
|
+
<GitBranchIcon size={14} />
|
|
745
|
+
<span className={css.branchBtnText}>{switching ? `Switching to ${switching}…` : currentBranch}</span>
|
|
746
|
+
{!switching && <ChevronDownIcon size={12} />}
|
|
747
|
+
</Menu.Trigger>
|
|
748
|
+
<Menu.Portal>
|
|
749
|
+
<Menu.Positioner className={css.branchPositioner} side="bottom" align="end" sideOffset={4}>
|
|
750
|
+
<Menu.Popup className={css.branchPopup}>
|
|
751
|
+
{myBranches.length > 0 && (
|
|
752
|
+
<>
|
|
753
|
+
<div className={css.branchSectionLabel}>My branches</div>
|
|
754
|
+
{myBranches.map(b => (
|
|
755
|
+
<Menu.Item
|
|
756
|
+
key={b.branch}
|
|
757
|
+
className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
|
|
758
|
+
onClick={() => switchBranch(b.branch)}
|
|
759
|
+
>
|
|
760
|
+
<GitBranchIcon size={12} />
|
|
761
|
+
{b.branch}
|
|
762
|
+
</Menu.Item>
|
|
763
|
+
))}
|
|
764
|
+
</>
|
|
765
|
+
)}
|
|
766
|
+
|
|
767
|
+
{myBranches.length > 0 && recentBranches.length > 0 && (
|
|
768
|
+
<div className={css.branchSeparator} />
|
|
769
|
+
)}
|
|
770
|
+
|
|
771
|
+
{recentBranches.length > 0 && (
|
|
772
|
+
<>
|
|
773
|
+
<div className={css.branchSectionLabel}>
|
|
774
|
+
{showAll ? 'All branches' : 'Recent branches'}
|
|
775
|
+
</div>
|
|
776
|
+
<Menu.Viewport className={css.branchViewport}>
|
|
777
|
+
{recentBranches.map(b => (
|
|
778
|
+
<Menu.Item
|
|
779
|
+
key={b.branch}
|
|
780
|
+
className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
|
|
781
|
+
onClick={() => switchBranch(b.branch)}
|
|
782
|
+
>
|
|
783
|
+
<GitBranchIcon size={12} />
|
|
784
|
+
{b.branch}
|
|
785
|
+
</Menu.Item>
|
|
786
|
+
))}
|
|
787
|
+
</Menu.Viewport>
|
|
788
|
+
</>
|
|
789
|
+
)}
|
|
790
|
+
|
|
791
|
+
{!showAll && otherBranches.length > recentBranches.length && (
|
|
792
|
+
<>
|
|
793
|
+
<div className={css.branchSeparator} />
|
|
794
|
+
<button
|
|
795
|
+
className={css.branchShowAll}
|
|
796
|
+
onClick={(e) => { e.stopPropagation(); setShowAll(true) }}
|
|
797
|
+
>
|
|
798
|
+
See all branches ({otherBranches.length})
|
|
799
|
+
</button>
|
|
800
|
+
</>
|
|
801
|
+
)}
|
|
802
|
+
</Menu.Popup>
|
|
803
|
+
</Menu.Positioner>
|
|
804
|
+
</Menu.Portal>
|
|
805
|
+
</Menu.Root>
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/* ─── Main Component ─── */
|
|
810
|
+
|
|
811
|
+
export default function ViewfinderNew({
|
|
812
|
+
pageModules = {},
|
|
813
|
+
basePath,
|
|
814
|
+
title = 'Storyboard',
|
|
815
|
+
subtitle,
|
|
816
|
+
hideDefaultFlow,
|
|
817
|
+
hideDefaultScene = false,
|
|
818
|
+
}) {
|
|
819
|
+
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
820
|
+
|
|
821
|
+
// Build data index from real prototype/canvas/story data
|
|
822
|
+
const knownRoutes = useMemo(() =>
|
|
823
|
+
Object.keys(pageModules)
|
|
824
|
+
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
825
|
+
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
|
|
826
|
+
[pageModules],
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
const prototypeIndex = useMemo(() => buildPrototypeIndex(knownRoutes), [knownRoutes])
|
|
830
|
+
|
|
831
|
+
// Build unified items list from all sources
|
|
832
|
+
const allItems = useMemo(() => {
|
|
833
|
+
const items = []
|
|
834
|
+
|
|
835
|
+
// Prototypes (ungrouped + from folders)
|
|
836
|
+
const addProto = (proto) => {
|
|
837
|
+
// For prototypes with flows, use the first flow's route
|
|
838
|
+
const route = proto.flows?.length > 0
|
|
839
|
+
? proto.flows[0].route
|
|
840
|
+
: `/${proto.dirName}`
|
|
841
|
+
|
|
842
|
+
items.push({
|
|
843
|
+
id: `proto:${proto.dirName}`,
|
|
844
|
+
name: proto.name,
|
|
845
|
+
type: 'prototype',
|
|
846
|
+
author: proto.author,
|
|
847
|
+
gitAuthor: proto.gitAuthor,
|
|
848
|
+
lastModified: proto.lastModified,
|
|
849
|
+
route,
|
|
850
|
+
isExternal: proto.isExternal,
|
|
851
|
+
externalUrl: proto.externalUrl,
|
|
852
|
+
folder: proto.folder,
|
|
853
|
+
description: proto.description,
|
|
854
|
+
flows: proto.flows || [],
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
for (const proto of prototypeIndex.prototypes || []) addProto(proto)
|
|
859
|
+
for (const folder of prototypeIndex.folders || []) {
|
|
860
|
+
for (const proto of folder.prototypes || []) addProto(proto)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Canvases (ungrouped + from folders)
|
|
864
|
+
const addCanvas = (canvas) => {
|
|
865
|
+
items.push({
|
|
866
|
+
id: `canvas:${canvas.dirName}`,
|
|
867
|
+
name: canvas.name,
|
|
868
|
+
type: 'canvas',
|
|
869
|
+
author: canvas.author,
|
|
870
|
+
gitAuthor: canvas.gitAuthor,
|
|
871
|
+
lastModified: null,
|
|
872
|
+
route: canvas.route,
|
|
873
|
+
isExternal: false,
|
|
874
|
+
externalUrl: null,
|
|
875
|
+
folder: canvas.folder,
|
|
876
|
+
description: canvas.description,
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
for (const canvas of prototypeIndex.canvases || []) addCanvas(canvas)
|
|
881
|
+
for (const folder of prototypeIndex.folders || []) {
|
|
882
|
+
for (const canvas of folder.canvases || []) addCanvas(canvas)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Components (stories)
|
|
886
|
+
const storyNames = listStories()
|
|
887
|
+
for (const name of storyNames) {
|
|
888
|
+
const data = getStoryData(name)
|
|
889
|
+
if (!data) continue
|
|
890
|
+
items.push({
|
|
891
|
+
id: `component:${name}`,
|
|
892
|
+
name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
893
|
+
type: 'component',
|
|
894
|
+
author: null,
|
|
895
|
+
gitAuthor: null,
|
|
896
|
+
lastModified: null,
|
|
897
|
+
route: data._route || `/components/${name}`,
|
|
898
|
+
isExternal: false,
|
|
899
|
+
externalUrl: null,
|
|
900
|
+
folder: null,
|
|
901
|
+
description: null,
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return items
|
|
906
|
+
}, [prototypeIndex])
|
|
907
|
+
|
|
908
|
+
const itemMap = useMemo(() => Object.fromEntries(allItems.map(i => [i.id, i])), [allItems])
|
|
909
|
+
|
|
910
|
+
// State
|
|
911
|
+
const [activeNav, setActiveNav] = useState('all')
|
|
912
|
+
const [activeTab, setActiveTab] = useState('All')
|
|
913
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
914
|
+
const [showPAT, setShowPAT] = useState(false)
|
|
915
|
+
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
916
|
+
const [groupByFolders, setGroupByFolders] = useState(() => {
|
|
917
|
+
try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
|
|
918
|
+
})
|
|
919
|
+
const [collapsedFolders, setCollapsedFolders] = useState(new Set())
|
|
920
|
+
const { starred, toggle: toggleStar } = useStarred()
|
|
921
|
+
const recentIds = useRecent()
|
|
922
|
+
|
|
923
|
+
// Filter by nav category
|
|
924
|
+
const navFiltered = useMemo(() => {
|
|
925
|
+
if (activeNav === 'all') return allItems
|
|
926
|
+
const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
|
|
927
|
+
return allItems.filter(i => i.type === typeMap[activeNav])
|
|
928
|
+
}, [allItems, activeNav])
|
|
929
|
+
|
|
930
|
+
// Filter by tab
|
|
931
|
+
const items = useMemo(() => {
|
|
932
|
+
if (activeTab === 'Recent') {
|
|
933
|
+
const ordered = recentIds.map(id => itemMap[id]).filter(Boolean)
|
|
934
|
+
if (activeNav !== 'all') {
|
|
935
|
+
const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
|
|
936
|
+
return ordered.filter(i => i.type === typeMap[activeNav])
|
|
937
|
+
}
|
|
938
|
+
return ordered
|
|
939
|
+
}
|
|
940
|
+
const base = activeTab === 'Starred'
|
|
941
|
+
? navFiltered.filter(i => starred.has(i.id))
|
|
942
|
+
: navFiltered
|
|
943
|
+
return [...base].sort((a, b) => {
|
|
944
|
+
const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
|
|
945
|
+
const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
|
|
946
|
+
return bTime - aTime
|
|
947
|
+
})
|
|
948
|
+
}, [activeTab, activeNav, navFiltered, recentIds, itemMap, starred])
|
|
949
|
+
|
|
950
|
+
// Grouped items for folder view
|
|
951
|
+
const grouped = useMemo(() => {
|
|
952
|
+
if (!groupByFolders) return null
|
|
953
|
+
const folderItems = {}
|
|
954
|
+
const ungrouped = []
|
|
955
|
+
for (const item of items) {
|
|
956
|
+
if (item.folder) {
|
|
957
|
+
if (!folderItems[item.folder]) folderItems[item.folder] = []
|
|
958
|
+
folderItems[item.folder].push(item)
|
|
959
|
+
} else {
|
|
960
|
+
ungrouped.push(item)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const folderMeta = {}
|
|
964
|
+
for (const f of prototypeIndex.folders || []) folderMeta[f.dirName] = f
|
|
965
|
+
const folders = Object.entries(folderItems).map(([dirName, fItems]) => ({
|
|
966
|
+
dirName,
|
|
967
|
+
name: folderMeta[dirName]?.name || dirName,
|
|
968
|
+
items: fItems,
|
|
969
|
+
}))
|
|
970
|
+
folders.sort((a, b) => {
|
|
971
|
+
const aMax = Math.max(0, ...a.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
|
|
972
|
+
const bMax = Math.max(0, ...b.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
|
|
973
|
+
return bMax - aMax
|
|
974
|
+
})
|
|
975
|
+
return { ungrouped, folders }
|
|
976
|
+
}, [items, groupByFolders, prototypeIndex])
|
|
977
|
+
|
|
978
|
+
const toggleGrouping = useCallback(() => {
|
|
979
|
+
setGroupByFolders(prev => {
|
|
980
|
+
const next = !prev
|
|
981
|
+
try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch {}
|
|
982
|
+
return next
|
|
983
|
+
})
|
|
984
|
+
}, [])
|
|
985
|
+
|
|
986
|
+
const toggleFolder = useCallback((dirName) => {
|
|
987
|
+
setCollapsedFolders(prev => {
|
|
988
|
+
const next = new Set(prev)
|
|
989
|
+
if (next.has(dirName)) next.delete(dirName)
|
|
990
|
+
else next.add(dirName)
|
|
991
|
+
return next
|
|
992
|
+
})
|
|
993
|
+
}, [])
|
|
994
|
+
|
|
995
|
+
// Counts
|
|
996
|
+
const counts = useMemo(() => ({
|
|
997
|
+
all: allItems.length,
|
|
998
|
+
prototypes: allItems.filter(i => i.type === 'prototype').length,
|
|
999
|
+
canvases: allItems.filter(i => i.type === 'canvas').length,
|
|
1000
|
+
components: allItems.filter(i => i.type === 'component').length,
|
|
1001
|
+
}), [allItems])
|
|
1002
|
+
|
|
1003
|
+
// Starred items for sidebar
|
|
1004
|
+
const starredItems = useMemo(() => allItems.filter(i => starred.has(i.id)), [allItems, starred])
|
|
1005
|
+
|
|
1006
|
+
const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
|
|
1007
|
+
|
|
1008
|
+
return (
|
|
1009
|
+
<div className={css.layout}>
|
|
1010
|
+
{/* ─── Full-width Header ─── */}
|
|
1011
|
+
<header className={css.topBar}>
|
|
1012
|
+
<div className={css.topBarLeft}>
|
|
1013
|
+
<button
|
|
1014
|
+
className={css.hamburgerBtn}
|
|
1015
|
+
onClick={() => setSidebarOpen(prev => !prev)}
|
|
1016
|
+
aria-label="Toggle menu"
|
|
1017
|
+
>
|
|
1018
|
+
{sidebarOpen ? <XIcon size={18} /> : <ThreeBarsIcon size={18} />}
|
|
1019
|
+
</button>
|
|
1020
|
+
<div className={`${css.logo} smooth-corners`}><Icon name="iconoir/key-command" size={22} color="#fff" /></div>
|
|
1021
|
+
<div>
|
|
1022
|
+
<div className={css.appName}>{title}</div>
|
|
1023
|
+
{subtitle && <div className={css.appSubtitle}>{subtitle}</div>}
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
<div className={css.topActions}>
|
|
1027
|
+
<BranchDropdown basePath={basePath} />
|
|
1028
|
+
<Menu.Root open={showCreate} onOpenChange={setShowCreate}>
|
|
1029
|
+
<Menu.Trigger className={css.createBtn}>
|
|
1030
|
+
+ Create
|
|
1031
|
+
</Menu.Trigger>
|
|
1032
|
+
<Menu.Portal>
|
|
1033
|
+
<Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
|
|
1034
|
+
<Menu.Popup className={css.createDropdown}>
|
|
1035
|
+
<CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
|
|
1036
|
+
</Menu.Popup>
|
|
1037
|
+
</Menu.Positioner>
|
|
1038
|
+
</Menu.Portal>
|
|
1039
|
+
</Menu.Root>
|
|
1040
|
+
</div>
|
|
1041
|
+
</header>
|
|
1042
|
+
|
|
1043
|
+
{/* ─── Body: Sidebar + Content ─── */}
|
|
1044
|
+
<div className={css.body}>
|
|
1045
|
+
{/* ─── Sidebar ─── */}
|
|
1046
|
+
<aside className={`${css.sidebar}${sidebarOpen ? ` ${css.sidebarOpen}` : ''}`}>
|
|
1047
|
+
<nav className={css.navSection}>
|
|
1048
|
+
{NAV_ITEMS.map(nav => (
|
|
1049
|
+
<button
|
|
1050
|
+
key={nav.id}
|
|
1051
|
+
className={activeNav === nav.id ? css.navItemActive : css.navItem}
|
|
1052
|
+
onClick={() => { setActiveNav(nav.id); setSidebarOpen(false) }}
|
|
1053
|
+
>
|
|
1054
|
+
<span className={css.navIcon}><Icon name={nav.iconName} size={16} /></span>
|
|
1055
|
+
{nav.label}
|
|
1056
|
+
<span className={css.navCount}>{counts[nav.id]}</span>
|
|
1057
|
+
</button>
|
|
1058
|
+
))}
|
|
1059
|
+
</nav>
|
|
1060
|
+
|
|
1061
|
+
<div className={css.separator} />
|
|
1062
|
+
|
|
1063
|
+
<div className={css.sectionLabel}>Starred</div>
|
|
1064
|
+
{starredItems.length === 0 && (
|
|
1065
|
+
<div className={css.starredEmpty}>Star items to pin them here</div>
|
|
1066
|
+
)}
|
|
1067
|
+
{starredItems.map(s => (
|
|
1068
|
+
<a
|
|
1069
|
+
key={s.id}
|
|
1070
|
+
className={css.starredItem}
|
|
1071
|
+
href={s.isExternal ? s.externalUrl : withBase(basePath, s.route)}
|
|
1072
|
+
{...(s.isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
1073
|
+
onClick={() => trackRecent(s.id)}
|
|
1074
|
+
>
|
|
1075
|
+
<span className={css.starredIcon}>{getTypeIcon(s.type)}</span>
|
|
1076
|
+
{s.name}
|
|
1077
|
+
</a>
|
|
1078
|
+
))}
|
|
1079
|
+
|
|
1080
|
+
{/* User profile / login */}
|
|
1081
|
+
<div className={css.sidebarFooter}>
|
|
1082
|
+
<button className={css.loginBtn} onClick={() => setShowPAT(true)}>
|
|
1083
|
+
<span className={css.avatar}><MarkGithubIcon size={16} /></span>
|
|
1084
|
+
<div>
|
|
1085
|
+
<div className={css.userName}>Sign in</div>
|
|
1086
|
+
<div className={css.userSub}>Connect with GitHub</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
</button>
|
|
1089
|
+
</div>
|
|
1090
|
+
</aside>
|
|
1091
|
+
|
|
1092
|
+
{/* ─── Main ─── */}
|
|
1093
|
+
<main className={css.main}>
|
|
1094
|
+
{/* Tabs */}
|
|
1095
|
+
<div className={css.tabs}>
|
|
1096
|
+
{TAB_FILTERS.map(t => (
|
|
1097
|
+
<button
|
|
1098
|
+
key={t}
|
|
1099
|
+
className={activeTab === t ? css.tabActive : css.tab}
|
|
1100
|
+
onClick={() => setActiveTab(t)}
|
|
1101
|
+
>
|
|
1102
|
+
{t}
|
|
1103
|
+
</button>
|
|
1104
|
+
))}
|
|
1105
|
+
<label className={css.groupByFolders}>
|
|
1106
|
+
<input
|
|
1107
|
+
type="checkbox"
|
|
1108
|
+
className={css.groupByFoldersCheckbox}
|
|
1109
|
+
checked={groupByFolders}
|
|
1110
|
+
onChange={toggleGrouping}
|
|
1111
|
+
/>
|
|
1112
|
+
Group by folders
|
|
1113
|
+
</label>
|
|
1114
|
+
</div>
|
|
1115
|
+
|
|
1116
|
+
{/* Grid */}
|
|
1117
|
+
<div className={css.content}>
|
|
1118
|
+
{items.length === 0 ? (
|
|
1119
|
+
<div className={css.emptyState}>
|
|
1120
|
+
{activeTab === 'Recent' && 'No recently opened items yet.'}
|
|
1121
|
+
{activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
|
|
1122
|
+
{activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
|
|
1123
|
+
</div>
|
|
1124
|
+
) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
|
|
1125
|
+
<>
|
|
1126
|
+
{grouped.folders.map(folder => (
|
|
1127
|
+
<FolderSection
|
|
1128
|
+
key={folder.dirName}
|
|
1129
|
+
folder={folder}
|
|
1130
|
+
collapsed={collapsedFolders.has(folder.dirName)}
|
|
1131
|
+
onToggle={() => toggleFolder(folder.dirName)}
|
|
1132
|
+
basePath={basePath}
|
|
1133
|
+
starred={starred}
|
|
1134
|
+
onToggleStar={toggleStar}
|
|
1135
|
+
/>
|
|
1136
|
+
))}
|
|
1137
|
+
{grouped.ungrouped.length > 0 && (
|
|
1138
|
+
<div className={css.grid}>
|
|
1139
|
+
{grouped.ungrouped.map(item => (
|
|
1140
|
+
<ArtifactCard
|
|
1141
|
+
key={item.id}
|
|
1142
|
+
item={item}
|
|
1143
|
+
basePath={basePath}
|
|
1144
|
+
starred={starred.has(item.id)}
|
|
1145
|
+
onToggleStar={toggleStar}
|
|
1146
|
+
/>
|
|
1147
|
+
))}
|
|
1148
|
+
</div>
|
|
1149
|
+
)}
|
|
1150
|
+
</>
|
|
1151
|
+
) : (
|
|
1152
|
+
<div className={css.grid}>
|
|
1153
|
+
{items.map(item => (
|
|
1154
|
+
<ArtifactCard
|
|
1155
|
+
key={item.id}
|
|
1156
|
+
item={item}
|
|
1157
|
+
basePath={basePath}
|
|
1158
|
+
starred={starred.has(item.id)}
|
|
1159
|
+
onToggleStar={toggleStar}
|
|
1160
|
+
/>
|
|
1161
|
+
))}
|
|
1162
|
+
</div>
|
|
1163
|
+
)}
|
|
1164
|
+
</div>
|
|
1165
|
+
</main>
|
|
1166
|
+
</div>
|
|
1167
|
+
|
|
1168
|
+
{/* Modals */}
|
|
1169
|
+
<PATDialog open={showPAT} onClose={() => setShowPAT(false)} />
|
|
1170
|
+
</div>
|
|
1171
|
+
)
|
|
1172
|
+
}
|