@dfosco/storyboard-react 4.2.0-beta.1 → 4.2.0-beta.17
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 +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +17 -5
- package/src/BranchBar/BranchBar.module.css +11 -2
- package/src/CommandPalette/CommandPalette.jsx +267 -164
- package/src/CommandPalette/command-palette.css +130 -78
- package/src/Icon.jsx +112 -48
- package/src/Viewfinder.jsx +511 -61
- package/src/Viewfinder.module.css +414 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +157 -174
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/ConnectorLayer.jsx +5 -5
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/widgets/ActionWidget.jsx +200 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/LinkPreview.jsx +64 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
- package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
- package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
- package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
- package/src/canvas/widgets/StoryWidget.jsx +7 -4
- package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
- package/src/canvas/widgets/TerminalWidget.jsx +299 -49
- package/src/canvas/widgets/TerminalWidget.module.css +155 -1
- package/src/canvas/widgets/WidgetChrome.jsx +19 -14
- package/src/canvas/widgets/WidgetChrome.module.css +10 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +188 -0
- package/src/canvas/widgets/index.js +5 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/widgetConfig.js +19 -1
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +4 -0
- package/src/vite/data-plugin.js +264 -14
package/src/Viewfinder.jsx
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Replaces the old list-based Viewfinder with a sidebar + grid layout.
|
|
5
5
|
* Wired to real data from buildPrototypeIndex and listStories.
|
|
6
6
|
*/
|
|
7
|
-
import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
|
|
7
|
+
import { useState, useEffect, useRef, useMemo, useCallback, useSyncExternalStore } from 'react'
|
|
8
8
|
import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
|
|
9
|
-
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon } from '@primer/octicons-react'
|
|
9
|
+
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon } from '@primer/octicons-react'
|
|
10
10
|
import { Menu } from '@base-ui/react/menu'
|
|
11
|
+
import { Dialog } from '@base-ui/react/dialog'
|
|
11
12
|
import Icon from './Icon.jsx'
|
|
12
13
|
import css from './Viewfinder.module.css'
|
|
13
14
|
|
|
@@ -45,6 +46,52 @@ function useToolbarTheme() {
|
|
|
45
46
|
return attrs
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/* ─── GitHub user hook ─── */
|
|
50
|
+
|
|
51
|
+
const COMMENTS_USER_KEY = 'sb-comments-user'
|
|
52
|
+
const COMMENTS_TOKEN_KEY = 'sb-comments-token'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the current GitHub user for display in the sidebar.
|
|
56
|
+
* Priority: 1) PAT-cached user (from comments auth), 2) gh CLI login via git-user endpoint.
|
|
57
|
+
*/
|
|
58
|
+
function useGitHubUser(basePath) {
|
|
59
|
+
const [user, setUser] = useState(() => {
|
|
60
|
+
try {
|
|
61
|
+
const raw = localStorage.getItem(COMMENTS_USER_KEY)
|
|
62
|
+
const token = localStorage.getItem(COMMENTS_TOKEN_KEY)
|
|
63
|
+
if (token && raw) {
|
|
64
|
+
const parsed = JSON.parse(raw)
|
|
65
|
+
if (parsed?.login) return parsed
|
|
66
|
+
}
|
|
67
|
+
} catch { /* ignore */ }
|
|
68
|
+
return null
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Listen for auth changes (when user signs in via AuthModal)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const handler = () => {
|
|
74
|
+
try {
|
|
75
|
+
const raw = localStorage.getItem(COMMENTS_USER_KEY)
|
|
76
|
+
const token = localStorage.getItem(COMMENTS_TOKEN_KEY)
|
|
77
|
+
if (token && raw) {
|
|
78
|
+
const parsed = JSON.parse(raw)
|
|
79
|
+
if (parsed?.login) { setUser(parsed); return }
|
|
80
|
+
}
|
|
81
|
+
setUser(null)
|
|
82
|
+
} catch { setUser(null) }
|
|
83
|
+
}
|
|
84
|
+
window.addEventListener('storage', handler)
|
|
85
|
+
document.addEventListener('storyboard:auth-changed', handler)
|
|
86
|
+
return () => {
|
|
87
|
+
window.removeEventListener('storage', handler)
|
|
88
|
+
document.removeEventListener('storyboard:auth-changed', handler)
|
|
89
|
+
}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
return user
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
/* ─── localStorage helpers ─── */
|
|
49
96
|
|
|
50
97
|
const STARRED_KEY = 'sb-viewfinder-starred'
|
|
@@ -157,10 +204,13 @@ function AvatarStack({ authors }) {
|
|
|
157
204
|
|
|
158
205
|
/* ─── Star Button ─── */
|
|
159
206
|
|
|
160
|
-
function StarBtn({ active, onClick }) {
|
|
207
|
+
function StarBtn({ active, onClick, inline }) {
|
|
208
|
+
const cls = inline
|
|
209
|
+
? (active ? css.iconBtnInlineActive : css.iconBtnInline)
|
|
210
|
+
: (active ? css.iconBtnActive : css.iconBtn)
|
|
161
211
|
return (
|
|
162
212
|
<button
|
|
163
|
-
className={
|
|
213
|
+
className={cls}
|
|
164
214
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick() }}
|
|
165
215
|
aria-label={active ? 'Remove favorite' : 'Favorite'}
|
|
166
216
|
title={active ? 'Remove favorite' : 'Favorite'}
|
|
@@ -170,9 +220,251 @@ function StarBtn({ active, onClick }) {
|
|
|
170
220
|
)
|
|
171
221
|
}
|
|
172
222
|
|
|
223
|
+
/* ─── Card Actions Menu ─── */
|
|
224
|
+
|
|
225
|
+
function CardActionsMenu({ typeLabel, onEdit, onDelete }) {
|
|
226
|
+
return (
|
|
227
|
+
<Menu.Root>
|
|
228
|
+
<Menu.Trigger
|
|
229
|
+
className={css.iconBtn}
|
|
230
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
|
|
231
|
+
aria-label="Actions"
|
|
232
|
+
render={<button />}
|
|
233
|
+
>
|
|
234
|
+
<KebabHorizontalIcon size={16} />
|
|
235
|
+
</Menu.Trigger>
|
|
236
|
+
<Menu.Portal>
|
|
237
|
+
<Menu.Positioner className={css.actionsMenuPositioner} side="bottom" alignment="end">
|
|
238
|
+
<Menu.Popup className={css.actionsMenu} onClick={(e) => { e.preventDefault(); e.stopPropagation() }}>
|
|
239
|
+
<Menu.Item
|
|
240
|
+
className={css.actionsMenuItem}
|
|
241
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
|
242
|
+
render={<button />}
|
|
243
|
+
>
|
|
244
|
+
<PencilIcon size={16} />
|
|
245
|
+
Edit {typeLabel}
|
|
246
|
+
</Menu.Item>
|
|
247
|
+
<Menu.Item
|
|
248
|
+
className={css.actionsMenuItemDanger}
|
|
249
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete() }}
|
|
250
|
+
render={<button />}
|
|
251
|
+
>
|
|
252
|
+
<TrashIcon size={16} />
|
|
253
|
+
Delete {typeLabel}
|
|
254
|
+
</Menu.Item>
|
|
255
|
+
</Menu.Popup>
|
|
256
|
+
</Menu.Positioner>
|
|
257
|
+
</Menu.Portal>
|
|
258
|
+
</Menu.Root>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ─── Edit Artifact Modal ─── */
|
|
263
|
+
|
|
264
|
+
function EditArtifactModal({ item, dirName, basePath, onClose }) {
|
|
265
|
+
const [name, setName] = useState(item.name || '')
|
|
266
|
+
const [description, setDescription] = useState(item.description || '')
|
|
267
|
+
const [author, setAuthor] = useState(
|
|
268
|
+
item.author
|
|
269
|
+
? (Array.isArray(item.author) ? item.author.join(', ') : item.author)
|
|
270
|
+
: ''
|
|
271
|
+
)
|
|
272
|
+
const [error, setError] = useState('')
|
|
273
|
+
const [submitting, setSubmitting] = useState(false)
|
|
274
|
+
const overlayRef = useRef(null)
|
|
275
|
+
|
|
276
|
+
const typeLabel = item.type === 'canvas' ? 'Canvas' : item.type === 'prototype' ? 'Prototype' : 'Component'
|
|
277
|
+
|
|
278
|
+
const handleSubmit = async (e) => {
|
|
279
|
+
e.preventDefault()
|
|
280
|
+
setError('')
|
|
281
|
+
setSubmitting(true)
|
|
282
|
+
|
|
283
|
+
const apiBase = (basePath || '/').replace(/\/+$/, '')
|
|
284
|
+
let endpoint
|
|
285
|
+
|
|
286
|
+
if (item.type === 'canvas') {
|
|
287
|
+
endpoint = `${apiBase}/_storyboard/canvas/update-meta`
|
|
288
|
+
} else if (item.type === 'prototype') {
|
|
289
|
+
endpoint = `${apiBase}/_storyboard/workshop/prototypes`
|
|
290
|
+
} else {
|
|
291
|
+
setError('Editing this type is not supported')
|
|
292
|
+
setSubmitting(false)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const body = {
|
|
297
|
+
name: dirName,
|
|
298
|
+
title: name.trim(),
|
|
299
|
+
description: description.trim(),
|
|
300
|
+
author: author.trim(),
|
|
301
|
+
}
|
|
302
|
+
if (item.folder) body.folder = item.folder
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(endpoint, {
|
|
306
|
+
method: 'PUT',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify(body),
|
|
309
|
+
})
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
const text = await res.text()
|
|
312
|
+
throw new Error(text || `Request failed (${res.status})`)
|
|
313
|
+
}
|
|
314
|
+
window.location.reload()
|
|
315
|
+
} catch (err) {
|
|
316
|
+
setError(err.message)
|
|
317
|
+
setSubmitting(false)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
const handleKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
323
|
+
document.addEventListener('keydown', handleKey)
|
|
324
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
325
|
+
}, [onClose])
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div className={css.modalOverlay} onClick={(e) => { if (e.target === overlayRef.current) onClose() }} ref={overlayRef}>
|
|
329
|
+
<div className={css.modalContent} onClick={(e) => e.stopPropagation()}>
|
|
330
|
+
<form onSubmit={handleSubmit}>
|
|
331
|
+
<div className={css.createFormHeader}>
|
|
332
|
+
<div className={css.createMenuTitle}>Edit {typeLabel}</div>
|
|
333
|
+
<button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
|
|
334
|
+
<XIcon size={16} />
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<div className={css.createFormField}>
|
|
339
|
+
<label className={css.createFormLabel}>Name</label>
|
|
340
|
+
<input
|
|
341
|
+
className={css.createFormInput}
|
|
342
|
+
value={name}
|
|
343
|
+
onChange={e => setName(e.target.value)}
|
|
344
|
+
autoFocus
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div className={css.createFormField}>
|
|
349
|
+
<label className={css.createFormLabel}>Description</label>
|
|
350
|
+
<input
|
|
351
|
+
className={css.createFormInput}
|
|
352
|
+
value={description}
|
|
353
|
+
onChange={e => setDescription(e.target.value)}
|
|
354
|
+
placeholder="Optional description"
|
|
355
|
+
/>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className={css.createFormField}>
|
|
359
|
+
<label className={css.createFormLabel}>Author</label>
|
|
360
|
+
<input
|
|
361
|
+
className={css.createFormInput}
|
|
362
|
+
value={author}
|
|
363
|
+
onChange={e => setAuthor(e.target.value)}
|
|
364
|
+
placeholder="GitHub username(s), comma-separated"
|
|
365
|
+
/>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{error && <div className={css.createFormError}>{error}</div>}
|
|
369
|
+
|
|
370
|
+
<div className={css.modalActions}>
|
|
371
|
+
<button type="button" className={css.modalCancelBtn} onClick={onClose}>Cancel</button>
|
|
372
|
+
<button type="submit" className={css.modalSubmitBtn} disabled={submitting}>
|
|
373
|
+
{submitting ? 'Saving…' : 'Save Changes'}
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
</form>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ─── Delete Artifact Modal ─── */
|
|
383
|
+
|
|
384
|
+
function DeleteArtifactModal({ item, dirName, basePath, typeLabel, onClose, onDeleted }) {
|
|
385
|
+
const [error, setError] = useState('')
|
|
386
|
+
const [deleting, setDeleting] = useState(false)
|
|
387
|
+
const overlayRef = useRef(null)
|
|
388
|
+
|
|
389
|
+
const handleDelete = async () => {
|
|
390
|
+
setError('')
|
|
391
|
+
setDeleting(true)
|
|
392
|
+
|
|
393
|
+
const apiBase = (basePath || '/').replace(/\/+$/, '')
|
|
394
|
+
let endpoint
|
|
395
|
+
|
|
396
|
+
if (item.type === 'canvas') {
|
|
397
|
+
endpoint = `${apiBase}/_storyboard/canvas/delete-canvas`
|
|
398
|
+
} else if (item.type === 'prototype') {
|
|
399
|
+
endpoint = `${apiBase}/_storyboard/workshop/prototypes`
|
|
400
|
+
} else {
|
|
401
|
+
setError('Deleting this type is not supported')
|
|
402
|
+
setDeleting(false)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const body = { name: dirName }
|
|
407
|
+
if (item.folder) body.folder = item.folder
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const res = await fetch(endpoint, {
|
|
411
|
+
method: 'DELETE',
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify(body),
|
|
414
|
+
})
|
|
415
|
+
if (!res.ok) {
|
|
416
|
+
const text = await res.text()
|
|
417
|
+
throw new Error(text || `Request failed (${res.status})`)
|
|
418
|
+
}
|
|
419
|
+
onDeleted?.()
|
|
420
|
+
onClose()
|
|
421
|
+
} catch (err) {
|
|
422
|
+
setError(err.message)
|
|
423
|
+
setDeleting(false)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const handleKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
429
|
+
document.addEventListener('keydown', handleKey)
|
|
430
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
431
|
+
}, [onClose])
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div className={css.modalOverlay} onClick={(e) => { if (e.target === overlayRef.current) onClose() }} ref={overlayRef}>
|
|
435
|
+
<div className={css.modalContent} onClick={(e) => e.stopPropagation()}>
|
|
436
|
+
<div className={css.createFormHeader}>
|
|
437
|
+
<div className={css.createMenuTitle}>Delete {typeLabel}</div>
|
|
438
|
+
<button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
|
|
439
|
+
<XIcon size={16} />
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<p className={css.deleteMessage}>
|
|
444
|
+
Are you sure you want to delete <strong>{item.name}</strong>? This action cannot be undone.
|
|
445
|
+
</p>
|
|
446
|
+
|
|
447
|
+
{error && <div className={css.createFormError}>{error}</div>}
|
|
448
|
+
|
|
449
|
+
<div className={css.modalActions}>
|
|
450
|
+
<button type="button" className={css.modalCancelBtn} onClick={onClose}>Cancel</button>
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
className={css.deleteConfirmBtn}
|
|
454
|
+
onClick={handleDelete}
|
|
455
|
+
disabled={deleting}
|
|
456
|
+
>
|
|
457
|
+
{deleting ? 'Deleting…' : `Delete ${typeLabel}`}
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
173
465
|
/* ─── Artifact Card ─── */
|
|
174
466
|
|
|
175
|
-
function ArtifactCard({ item, basePath, starred, onToggleStar }) {
|
|
467
|
+
function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted }) {
|
|
176
468
|
const href = item.route ? withBase(basePath, item.route) : '#'
|
|
177
469
|
const isExternal = item.isExternal
|
|
178
470
|
|
|
@@ -189,36 +481,73 @@ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
|
|
|
189
481
|
? (Array.isArray(item.author) ? item.author : [item.author])
|
|
190
482
|
: item.gitAuthor ? [item.gitAuthor] : []
|
|
191
483
|
|
|
484
|
+
const [showEdit, setShowEdit] = useState(false)
|
|
485
|
+
const [showDelete, setShowDelete] = useState(false)
|
|
486
|
+
|
|
487
|
+
// Extract dirName from item.id (format: "type:dirName")
|
|
488
|
+
const dirName = item.id.split(':').slice(1).join(':')
|
|
489
|
+
const typeLabel = item.type === 'canvas' ? 'Canvas' : item.type === 'prototype' ? 'Prototype' : 'Component'
|
|
490
|
+
const canEditDelete = item.type === 'canvas' || item.type === 'prototype'
|
|
491
|
+
|
|
192
492
|
return (
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
493
|
+
<>
|
|
494
|
+
<Tag className={css.card} {...linkProps} onClick={handleClick}>
|
|
495
|
+
<div className={css.cardHeader}>
|
|
496
|
+
<span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
|
|
497
|
+
<div className={css.cardActions}>
|
|
498
|
+
{item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
|
|
499
|
+
{item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
|
|
500
|
+
{canEditDelete && (
|
|
501
|
+
<CardActionsMenu
|
|
502
|
+
typeLabel={typeLabel}
|
|
503
|
+
onEdit={() => setShowEdit(true)}
|
|
504
|
+
onDelete={() => setShowDelete(true)}
|
|
505
|
+
/>
|
|
506
|
+
)}
|
|
207
507
|
</div>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{
|
|
508
|
+
</div>
|
|
509
|
+
<div className={css.cardBody}>
|
|
510
|
+
<div className={css.cardBodyContent}>
|
|
511
|
+
<div className={css.cardTitleRow}>
|
|
512
|
+
<div className={css.cardTitle}>
|
|
513
|
+
{item.name}
|
|
514
|
+
{isExternal && <span className={css.externalBadge}>↗</span>}
|
|
515
|
+
</div>
|
|
516
|
+
<StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
|
|
517
|
+
</div>
|
|
518
|
+
{item.description && (
|
|
519
|
+
<div className={css.cardDescription}>{item.description}</div>
|
|
520
|
+
)}
|
|
521
|
+
<div className={css.cardFooter}>
|
|
522
|
+
<AvatarStack authors={authorList} />
|
|
523
|
+
<div className={css.cardMeta}>
|
|
524
|
+
{authorList.length > 0 && <span>{authorList.join(', ')}</span>}
|
|
525
|
+
{authorList.length > 0 && formatRelativeTime(item.lastModified) && <span className={css.cardMetaDot} />}
|
|
526
|
+
{formatRelativeTime(item.lastModified) && <span>{formatRelativeTime(item.lastModified)}</span>}
|
|
527
|
+
</div>
|
|
217
528
|
</div>
|
|
218
529
|
</div>
|
|
219
530
|
</div>
|
|
220
|
-
</
|
|
221
|
-
|
|
531
|
+
</Tag>
|
|
532
|
+
{showEdit && (
|
|
533
|
+
<EditArtifactModal
|
|
534
|
+
item={item}
|
|
535
|
+
dirName={dirName}
|
|
536
|
+
basePath={basePath}
|
|
537
|
+
onClose={() => setShowEdit(false)}
|
|
538
|
+
/>
|
|
539
|
+
)}
|
|
540
|
+
{showDelete && (
|
|
541
|
+
<DeleteArtifactModal
|
|
542
|
+
item={item}
|
|
543
|
+
dirName={dirName}
|
|
544
|
+
basePath={basePath}
|
|
545
|
+
typeLabel={typeLabel}
|
|
546
|
+
onClose={() => setShowDelete(false)}
|
|
547
|
+
onDeleted={() => onItemDeleted?.(item.id)}
|
|
548
|
+
/>
|
|
549
|
+
)}
|
|
550
|
+
</>
|
|
222
551
|
)
|
|
223
552
|
}
|
|
224
553
|
|
|
@@ -316,7 +645,7 @@ function PagesDropdown({ pages, basePath }) {
|
|
|
316
645
|
|
|
317
646
|
/* ─── Folder Section ─── */
|
|
318
647
|
|
|
319
|
-
function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
|
|
648
|
+
function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar, onItemDeleted }) {
|
|
320
649
|
return (
|
|
321
650
|
<section className={collapsed ? css.folderSectionCollapsed : css.folderSection}>
|
|
322
651
|
<button className={css.folderHeader} onClick={onToggle}>
|
|
@@ -337,6 +666,7 @@ function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggl
|
|
|
337
666
|
basePath={basePath}
|
|
338
667
|
starred={starred.has(item.id)}
|
|
339
668
|
onToggleStar={onToggleStar}
|
|
669
|
+
onItemDeleted={onItemDeleted}
|
|
340
670
|
/>
|
|
341
671
|
))}
|
|
342
672
|
</div>
|
|
@@ -753,6 +1083,74 @@ function BranchDropdown({ basePath }) {
|
|
|
753
1083
|
)
|
|
754
1084
|
}
|
|
755
1085
|
|
|
1086
|
+
/* ─── User Settings Dialog ─── */
|
|
1087
|
+
|
|
1088
|
+
function UserSettingsDialog({ open, onOpenChange, user, onRemoveToken }) {
|
|
1089
|
+
const hasToken = (() => {
|
|
1090
|
+
try { return !!localStorage.getItem(COMMENTS_TOKEN_KEY) } catch { return false }
|
|
1091
|
+
})()
|
|
1092
|
+
const scopes = user?.scopes || []
|
|
1093
|
+
const isFineGrained = hasToken && scopes.length === 0
|
|
1094
|
+
|
|
1095
|
+
return (
|
|
1096
|
+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
1097
|
+
<Dialog.Portal>
|
|
1098
|
+
<Dialog.Backdrop className={css.settingsBackdrop} />
|
|
1099
|
+
<div className={css.settingsPopupWrap}>
|
|
1100
|
+
<Dialog.Popup className={css.settingsPopup}>
|
|
1101
|
+
<Dialog.Title className={css.settingsTitle}>Settings</Dialog.Title>
|
|
1102
|
+
<Dialog.Close className={css.settingsCloseBtn} aria-label="Close">×</Dialog.Close>
|
|
1103
|
+
|
|
1104
|
+
{/* GitHub connection section */}
|
|
1105
|
+
<div className={css.settingsSection}>
|
|
1106
|
+
<div className={css.settingsSectionHeader}>
|
|
1107
|
+
<ShieldLockIcon size={16} />
|
|
1108
|
+
<span>GitHub Connection</span>
|
|
1109
|
+
</div>
|
|
1110
|
+
|
|
1111
|
+
{hasToken ? (
|
|
1112
|
+
<div className={css.settingsTokenCard}>
|
|
1113
|
+
<div className={css.settingsTokenRow}>
|
|
1114
|
+
<span className={css.settingsTokenLabel}>Token</span>
|
|
1115
|
+
<code className={css.settingsTokenValue}>••••••••••••••••</code>
|
|
1116
|
+
</div>
|
|
1117
|
+
<div className={css.settingsTokenRow}>
|
|
1118
|
+
<span className={css.settingsTokenLabel}>Permissions</span>
|
|
1119
|
+
<span className={css.settingsTokenValue}>
|
|
1120
|
+
{isFineGrained
|
|
1121
|
+
? 'Fine-grained token'
|
|
1122
|
+
: scopes.map(s => <code key={s} className={css.settingsScope}>{s}</code>)
|
|
1123
|
+
}
|
|
1124
|
+
</span>
|
|
1125
|
+
</div>
|
|
1126
|
+
<button className={css.settingsRemoveBtn} onClick={onRemoveToken}>
|
|
1127
|
+
<TrashIcon size={14} />
|
|
1128
|
+
Remove token
|
|
1129
|
+
</button>
|
|
1130
|
+
</div>
|
|
1131
|
+
) : (
|
|
1132
|
+
<div className={css.settingsNoToken}>
|
|
1133
|
+
<p>No GitHub token configured.</p>
|
|
1134
|
+
<button
|
|
1135
|
+
className={css.settingsSignInBtn}
|
|
1136
|
+
onClick={() => {
|
|
1137
|
+
onOpenChange(false)
|
|
1138
|
+
document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))
|
|
1139
|
+
}}
|
|
1140
|
+
>
|
|
1141
|
+
<MarkGithubIcon size={16} />
|
|
1142
|
+
Sign in with GitHub
|
|
1143
|
+
</button>
|
|
1144
|
+
</div>
|
|
1145
|
+
)}
|
|
1146
|
+
</div>
|
|
1147
|
+
</Dialog.Popup>
|
|
1148
|
+
</div>
|
|
1149
|
+
</Dialog.Portal>
|
|
1150
|
+
</Dialog.Root>
|
|
1151
|
+
)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
756
1154
|
/* ─── Main Component ─── */
|
|
757
1155
|
|
|
758
1156
|
export default function Viewfinder({
|
|
@@ -765,6 +1163,17 @@ export default function Viewfinder({
|
|
|
765
1163
|
}) {
|
|
766
1164
|
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
767
1165
|
const themeAttrs = useToolbarTheme()
|
|
1166
|
+
const ghUser = useGitHubUser(basePath)
|
|
1167
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1168
|
+
|
|
1169
|
+
const handleRemoveToken = useCallback(() => {
|
|
1170
|
+
try {
|
|
1171
|
+
localStorage.removeItem(COMMENTS_TOKEN_KEY)
|
|
1172
|
+
localStorage.removeItem(COMMENTS_USER_KEY)
|
|
1173
|
+
} catch { /* ignore */ }
|
|
1174
|
+
document.dispatchEvent(new CustomEvent('storyboard:auth-changed'))
|
|
1175
|
+
setSettingsOpen(false)
|
|
1176
|
+
}, [])
|
|
768
1177
|
|
|
769
1178
|
// Build data index from real prototype/canvas/story data
|
|
770
1179
|
const knownRoutes = useMemo(() =>
|
|
@@ -860,20 +1269,23 @@ export default function Viewfinder({
|
|
|
860
1269
|
const [activeNav, setActiveNav] = useState('all')
|
|
861
1270
|
const [activeTab, setActiveTab] = useState('All')
|
|
862
1271
|
const [showCreate, setShowCreate] = useState(false)
|
|
1272
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
863
1273
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
864
1274
|
const [groupByFolders, setGroupByFolders] = useState(() => {
|
|
865
1275
|
try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
|
|
866
1276
|
})
|
|
867
1277
|
const [collapsedFolders, setCollapsedFolders] = useState(new Set())
|
|
1278
|
+
const [hiddenItems, setHiddenItems] = useState(new Set())
|
|
868
1279
|
const { starred, toggle: toggleStar } = useStarred()
|
|
869
1280
|
const recentIds = useRecent()
|
|
870
1281
|
|
|
871
1282
|
// Filter by nav category
|
|
1283
|
+
const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
|
|
872
1284
|
const navFiltered = useMemo(() => {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
return
|
|
876
|
-
}, [allItems, activeNav])
|
|
1285
|
+
let filtered = activeNav === 'all' ? allItems : allItems.filter(i => i.type === typeMap[activeNav])
|
|
1286
|
+
if (hiddenItems.size > 0) filtered = filtered.filter(i => !hiddenItems.has(i.id))
|
|
1287
|
+
return filtered
|
|
1288
|
+
}, [allItems, activeNav, hiddenItems])
|
|
877
1289
|
|
|
878
1290
|
// Filter by tab
|
|
879
1291
|
const items = useMemo(() => {
|
|
@@ -940,16 +1352,24 @@ export default function Viewfinder({
|
|
|
940
1352
|
})
|
|
941
1353
|
}, [])
|
|
942
1354
|
|
|
1355
|
+
const handleItemDeleted = useCallback((itemId) => {
|
|
1356
|
+
setHiddenItems(prev => new Set(prev).add(itemId))
|
|
1357
|
+
}, [])
|
|
1358
|
+
|
|
943
1359
|
// Counts
|
|
1360
|
+
const visibleItems = useMemo(() =>
|
|
1361
|
+
hiddenItems.size > 0 ? allItems.filter(i => !hiddenItems.has(i.id)) : allItems
|
|
1362
|
+
, [allItems, hiddenItems])
|
|
1363
|
+
|
|
944
1364
|
const counts = useMemo(() => ({
|
|
945
|
-
all:
|
|
946
|
-
prototypes:
|
|
947
|
-
canvases:
|
|
948
|
-
components:
|
|
949
|
-
}), [
|
|
1365
|
+
all: visibleItems.length,
|
|
1366
|
+
prototypes: visibleItems.filter(i => i.type === 'prototype').length,
|
|
1367
|
+
canvases: visibleItems.filter(i => i.type === 'canvas').length,
|
|
1368
|
+
components: visibleItems.filter(i => i.type === 'component').length,
|
|
1369
|
+
}), [visibleItems])
|
|
950
1370
|
|
|
951
1371
|
// Starred items for sidebar
|
|
952
|
-
const starredItems = useMemo(() =>
|
|
1372
|
+
const starredItems = useMemo(() => visibleItems.filter(i => starred.has(i.id)), [visibleItems, starred])
|
|
953
1373
|
|
|
954
1374
|
const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
|
|
955
1375
|
|
|
@@ -973,18 +1393,20 @@ export default function Viewfinder({
|
|
|
973
1393
|
</div>
|
|
974
1394
|
<div className={css.topActions}>
|
|
975
1395
|
<BranchDropdown basePath={basePath} />
|
|
976
|
-
|
|
977
|
-
<Menu.
|
|
978
|
-
<
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
<Menu.
|
|
982
|
-
<Menu.
|
|
983
|
-
<
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1396
|
+
{isLocalDev && (
|
|
1397
|
+
<Menu.Root open={showCreate} onOpenChange={setShowCreate}>
|
|
1398
|
+
<Menu.Trigger className={css.createBtn}>
|
|
1399
|
+
<PlusIcon size={14} /> Create
|
|
1400
|
+
</Menu.Trigger>
|
|
1401
|
+
<Menu.Portal>
|
|
1402
|
+
<Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
|
|
1403
|
+
<Menu.Popup className={css.createDropdown}>
|
|
1404
|
+
<CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
|
|
1405
|
+
</Menu.Popup>
|
|
1406
|
+
</Menu.Positioner>
|
|
1407
|
+
</Menu.Portal>
|
|
1408
|
+
</Menu.Root>
|
|
1409
|
+
)}
|
|
988
1410
|
</div>
|
|
989
1411
|
</header>
|
|
990
1412
|
|
|
@@ -1027,16 +1449,41 @@ export default function Viewfinder({
|
|
|
1027
1449
|
))}
|
|
1028
1450
|
</div>
|
|
1029
1451
|
|
|
1030
|
-
{/* User profile /
|
|
1452
|
+
{/* User profile / settings */}
|
|
1031
1453
|
<div className={css.sidebarFooter}>
|
|
1032
|
-
|
|
1033
|
-
<
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1454
|
+
{ghUser ? (
|
|
1455
|
+
<div className={css.footerRow}>
|
|
1456
|
+
<button className={css.userBtn} onClick={() => setSettingsOpen(true)}>
|
|
1457
|
+
<img
|
|
1458
|
+
className={css.userAvatar}
|
|
1459
|
+
src={ghUser.avatarUrl || `https://github.com/${ghUser.login}.png?size=64`}
|
|
1460
|
+
alt={ghUser.login}
|
|
1461
|
+
width={32}
|
|
1462
|
+
height={32}
|
|
1463
|
+
/>
|
|
1464
|
+
<div className={css.userInfo}>
|
|
1465
|
+
<div className={css.userName}>{ghUser.login}</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
</button>
|
|
1037
1468
|
</div>
|
|
1038
|
-
|
|
1469
|
+
) : (
|
|
1470
|
+
<div className={css.footerRow}>
|
|
1471
|
+
<button className={css.loginBtn} onClick={() => document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))}>
|
|
1472
|
+
<span className={css.avatar}><MarkGithubIcon size={16} /></span>
|
|
1473
|
+
<div>
|
|
1474
|
+
<div className={css.userName}>Sign in</div>
|
|
1475
|
+
<div className={css.userSub}>Connect with GitHub</div>
|
|
1476
|
+
</div>
|
|
1477
|
+
</button>
|
|
1478
|
+
</div>
|
|
1479
|
+
)}
|
|
1039
1480
|
</div>
|
|
1481
|
+
<UserSettingsDialog
|
|
1482
|
+
open={settingsOpen}
|
|
1483
|
+
onOpenChange={setSettingsOpen}
|
|
1484
|
+
user={ghUser}
|
|
1485
|
+
onRemoveToken={handleRemoveToken}
|
|
1486
|
+
/>
|
|
1040
1487
|
</aside>
|
|
1041
1488
|
|
|
1042
1489
|
{/* ─── Main ─── */}
|
|
@@ -1071,7 +1518,7 @@ export default function Viewfinder({
|
|
|
1071
1518
|
{activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
|
|
1072
1519
|
{activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
|
|
1073
1520
|
</div>
|
|
1074
|
-
) : groupByFolders && grouped && activeTab === 'All'
|
|
1521
|
+
) : groupByFolders && grouped && activeTab === 'All' ? (
|
|
1075
1522
|
<>
|
|
1076
1523
|
{grouped.folders.map(folder => (
|
|
1077
1524
|
<FolderSection
|
|
@@ -1082,6 +1529,7 @@ export default function Viewfinder({
|
|
|
1082
1529
|
basePath={basePath}
|
|
1083
1530
|
starred={starred}
|
|
1084
1531
|
onToggleStar={toggleStar}
|
|
1532
|
+
onItemDeleted={handleItemDeleted}
|
|
1085
1533
|
/>
|
|
1086
1534
|
))}
|
|
1087
1535
|
{grouped.ungrouped.length > 0 && (
|
|
@@ -1093,6 +1541,7 @@ export default function Viewfinder({
|
|
|
1093
1541
|
basePath={basePath}
|
|
1094
1542
|
starred={starred.has(item.id)}
|
|
1095
1543
|
onToggleStar={toggleStar}
|
|
1544
|
+
onItemDeleted={handleItemDeleted}
|
|
1096
1545
|
/>
|
|
1097
1546
|
))}
|
|
1098
1547
|
</div>
|
|
@@ -1107,6 +1556,7 @@ export default function Viewfinder({
|
|
|
1107
1556
|
basePath={basePath}
|
|
1108
1557
|
starred={starred.has(item.id)}
|
|
1109
1558
|
onToggleStar={toggleStar}
|
|
1559
|
+
onItemDeleted={handleItemDeleted}
|
|
1110
1560
|
/>
|
|
1111
1561
|
))}
|
|
1112
1562
|
</div>
|