@dfosco/storyboard-core 3.6.0 → 3.7.0
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/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +12274 -11387
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -1
- package/src/CanvasZoomControl.svelte +8 -8
- package/src/CommentsMenuButton.svelte +7 -21
- package/src/CoreUIBar.svelte +19 -3
- package/src/CreateMenuButton.svelte +8 -12
- package/src/InspectorPanel.svelte +12 -15
- package/src/SidePanel.svelte +14 -14
- package/src/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
- package/src/comments/ui/AuthModal.svelte +45 -12
- package/src/comments/ui/authModal.js +6 -1
- package/src/comments/ui/comment-layout.css +15 -15
- package/src/comments/ui/commentWindow.js +6 -1
- package/src/comments/ui/comments.css +57 -57
- package/src/comments/ui/commentsDrawer.js +2 -0
- package/src/comments/ui/composer.js +7 -2
- package/src/comments/ui/mount.js +252 -33
- package/src/comments/ui/mount.test.js +138 -0
- package/src/core-ui-colors.css +28 -28
- package/src/inspector/mouseMode.js +2 -2
- package/src/lib/components/ui/button/button.svelte +9 -9
- package/src/lib/components/ui/panel/panel-content.svelte +2 -2
- package/src/lib/components/ui/select/select-trigger.svelte +1 -1
- package/src/lib/components/ui/toggle/toggle.svelte +1 -1
- package/src/lib/components/ui/toggle-group/toggle-group.svelte +2 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +13 -13
- package/src/modes.css +21 -21
- package/src/mountStoryboardCore.js +4 -4
- package/src/sidepanel.css +11 -11
- package/src/styles/tailwind.css +89 -1
- package/src/svelte-plugin-ui/components/ModeSwitch.svelte +3 -3
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +31 -11
- package/src/svelte-plugin-ui/styles/base.css +41 -41
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +187 -25
- package/src/workshop/features/createFlow/server.js +437 -40
- package/src/workshop/features/createPage/CreatePageForm.svelte +249 -0
- package/src/workshop/features/createPage/index.js +11 -0
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +77 -24
- package/src/workshop/features/createPrototype/server.js +14 -16
- package/src/workshop/features/registry-server.js +1 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/workshop/features/templateIndex.js +155 -0
- package/toolbar.config.json +2 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
CreatePageForm — workshop form for creating a new page inside a prototype.
|
|
3
|
+
-->
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { onMount } from 'svelte'
|
|
7
|
+
import { Button } from '../../../lib/components/ui/button/index.js'
|
|
8
|
+
import { Input } from '../../../lib/components/ui/input/index.js'
|
|
9
|
+
import { Label } from '../../../lib/components/ui/label/index.js'
|
|
10
|
+
import * as Panel from '../../../lib/components/ui/panel/index.js'
|
|
11
|
+
import * as DropdownMenu from '../../../lib/components/ui/dropdown-menu/index.js'
|
|
12
|
+
import * as Alert from '../../../lib/components/ui/alert/index.js'
|
|
13
|
+
|
|
14
|
+
interface Props { onClose?: () => void }
|
|
15
|
+
let { onClose }: Props = $props()
|
|
16
|
+
|
|
17
|
+
interface PrototypeEntry { name: string; folder?: string }
|
|
18
|
+
interface PartialEntry {
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
kind: 'template' | 'recipe'
|
|
22
|
+
scope: 'global' | 'prototype'
|
|
23
|
+
prototype?: string
|
|
24
|
+
folder?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let selectedPrototype = $state('')
|
|
28
|
+
let pagePath = $state('')
|
|
29
|
+
let template = $state('')
|
|
30
|
+
let prototypes: PrototypeEntry[] = $state([])
|
|
31
|
+
let partials: PartialEntry[] = $state([])
|
|
32
|
+
let loading = $state(true)
|
|
33
|
+
let submitting = $state(false)
|
|
34
|
+
let error: string | null = $state(null)
|
|
35
|
+
let success: string | null = $state(null)
|
|
36
|
+
let templateMenuOpen = $state(false)
|
|
37
|
+
|
|
38
|
+
const selectedProtoEntry = $derived(
|
|
39
|
+
selectedPrototype ? prototypes.find((p) => p.name === selectedPrototype) : null
|
|
40
|
+
)
|
|
41
|
+
const pagePrefix = $derived(selectedPrototype ? `/${selectedPrototype}/` : '/prototype-name/')
|
|
42
|
+
const pageSuffix = $derived(
|
|
43
|
+
pagePath.startsWith(pagePrefix) ? pagePath.slice(pagePrefix.length) : pagePath
|
|
44
|
+
)
|
|
45
|
+
const canSubmit = $derived(!!selectedPrototype && !!pageSuffix.trim() && !submitting)
|
|
46
|
+
const templateChoices = $derived(
|
|
47
|
+
selectedPrototype
|
|
48
|
+
? partials.filter((partial) => {
|
|
49
|
+
if (partial.scope === 'global') return true
|
|
50
|
+
return partial.prototype === selectedPrototype && (partial.folder || '') === (selectedProtoEntry?.folder || '')
|
|
51
|
+
})
|
|
52
|
+
: partials.filter((partial) => partial.scope === 'global')
|
|
53
|
+
)
|
|
54
|
+
const globalTemplateChoices = $derived(templateChoices.filter((partial) => partial.scope === 'global'))
|
|
55
|
+
const localTemplateChoices = $derived(
|
|
56
|
+
templateChoices.filter((partial) => partial.scope === 'prototype')
|
|
57
|
+
)
|
|
58
|
+
const localTemplateHeading = $derived(
|
|
59
|
+
selectedPrototype || ''
|
|
60
|
+
)
|
|
61
|
+
const globalTemplates = $derived(globalTemplateChoices.filter((partial) => partial.kind === 'template'))
|
|
62
|
+
const globalRecipes = $derived(globalTemplateChoices.filter((partial) => partial.kind === 'recipe'))
|
|
63
|
+
const localTemplates = $derived(localTemplateChoices.filter((partial) => partial.kind === 'template'))
|
|
64
|
+
const localRecipes = $derived(localTemplateChoices.filter((partial) => partial.kind === 'recipe'))
|
|
65
|
+
const templateLabel = $derived(
|
|
66
|
+
template ? templateChoices.find((choice) => choice.id === template)?.name ?? template : 'Blank page'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
function getApiUrl() {
|
|
70
|
+
const basePath = document.querySelector('base')?.getAttribute('href') || '/'
|
|
71
|
+
return basePath.replace(/\/$/, '') + '/_storyboard/workshop/pages'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onMount(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(getApiUrl())
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
const data = await res.json()
|
|
79
|
+
prototypes = data.prototypes || []
|
|
80
|
+
partials = data.partials || []
|
|
81
|
+
}
|
|
82
|
+
} catch { /* defaults */ } finally { loading = false }
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
$effect(() => {
|
|
86
|
+
if (!selectedPrototype) {
|
|
87
|
+
pagePath = ''
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (!pagePath || !pagePath.startsWith(pagePrefix)) {
|
|
91
|
+
pagePath = `${pagePrefix}new-page`
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (!template) return
|
|
97
|
+
const stillAvailable = templateChoices.some((choice) => choice.id === template)
|
|
98
|
+
if (!stillAvailable) {
|
|
99
|
+
template = ''
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
async function submit() {
|
|
104
|
+
if (!canSubmit) return
|
|
105
|
+
submitting = true; error = null; success = null
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(getApiUrl(), {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
prototype: selectedPrototype,
|
|
112
|
+
folder: selectedProtoEntry?.folder || undefined,
|
|
113
|
+
path: pagePath.trim(),
|
|
114
|
+
template: template || undefined,
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
const data = await res.json()
|
|
118
|
+
if (!res.ok) { error = data.error || 'Failed to create page'; return }
|
|
119
|
+
success = `Created ${data.path}`
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
error = err.message || 'Network error'
|
|
122
|
+
} finally {
|
|
123
|
+
submitting = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && canSubmit) submit() }
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<Panel.Header>
|
|
131
|
+
<Panel.Title>Create page</Panel.Title>
|
|
132
|
+
<Panel.Close />
|
|
133
|
+
</Panel.Header>
|
|
134
|
+
|
|
135
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
136
|
+
<div class="p-4 pt-2 space-y-5" onkeydown={handleKeydown}>
|
|
137
|
+
<div class="space-y-1">
|
|
138
|
+
<Label for="sb-page-prototype">Prototype</Label>
|
|
139
|
+
<select
|
|
140
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
141
|
+
id="sb-page-prototype"
|
|
142
|
+
bind:value={selectedPrototype}
|
|
143
|
+
disabled={loading}
|
|
144
|
+
>
|
|
145
|
+
<option value="">Select prototype</option>
|
|
146
|
+
{#each prototypes as p}
|
|
147
|
+
<option value={p.name}>{p.folder ? `${p.folder} / ${p.name}` : p.name}</option>
|
|
148
|
+
{/each}
|
|
149
|
+
</select>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div class="space-y-1">
|
|
153
|
+
<Label for="sb-page-path">Page path</Label>
|
|
154
|
+
<div class="flex items-center gap-2">
|
|
155
|
+
<span class="text-xs text-muted-foreground font-mono whitespace-nowrap">{pagePrefix}</span>
|
|
156
|
+
<Input
|
|
157
|
+
id="sb-page-path"
|
|
158
|
+
placeholder="new-page"
|
|
159
|
+
value={pageSuffix}
|
|
160
|
+
oninput={(e: Event) => {
|
|
161
|
+
const suffix = (e.target as HTMLInputElement).value.replace(/^\/+/, '')
|
|
162
|
+
pagePath = `${pagePrefix}${suffix}`
|
|
163
|
+
}}
|
|
164
|
+
disabled={!selectedPrototype}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="space-y-1">
|
|
170
|
+
<Label>Template / recipe</Label>
|
|
171
|
+
<DropdownMenu.Root bind:open={templateMenuOpen}>
|
|
172
|
+
<DropdownMenu.Trigger>
|
|
173
|
+
{#snippet child({ props })}
|
|
174
|
+
<button
|
|
175
|
+
{...props}
|
|
176
|
+
class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
177
|
+
disabled={!selectedPrototype}
|
|
178
|
+
>
|
|
179
|
+
<span class={template ? 'text-foreground' : 'text-muted-foreground'}>{templateLabel}</span>
|
|
180
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m6 9 6 6 6-6"/></svg>
|
|
181
|
+
</button>
|
|
182
|
+
{/snippet}
|
|
183
|
+
</DropdownMenu.Trigger>
|
|
184
|
+
<DropdownMenu.Content side="left" align="start" sideOffset={8} class="min-w-[220px]">
|
|
185
|
+
{#if template}
|
|
186
|
+
<DropdownMenu.Item onclick={() => { template = ''; templateMenuOpen = false }}>
|
|
187
|
+
<span class="text-muted-foreground">Blank page</span>
|
|
188
|
+
</DropdownMenu.Item>
|
|
189
|
+
<DropdownMenu.Separator />
|
|
190
|
+
{/if}
|
|
191
|
+
|
|
192
|
+
{#if globalTemplates.length > 0}
|
|
193
|
+
<DropdownMenu.Group>
|
|
194
|
+
<DropdownMenu.GroupHeading>Templates</DropdownMenu.GroupHeading>
|
|
195
|
+
{#each globalTemplates as item (item.id)}
|
|
196
|
+
<DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
|
|
197
|
+
{item.name}
|
|
198
|
+
</DropdownMenu.Item>
|
|
199
|
+
{/each}
|
|
200
|
+
</DropdownMenu.Group>
|
|
201
|
+
{/if}
|
|
202
|
+
|
|
203
|
+
{#if localTemplates.length > 0}
|
|
204
|
+
<DropdownMenu.Separator />
|
|
205
|
+
<DropdownMenu.Group>
|
|
206
|
+
<DropdownMenu.GroupHeading>{localTemplateHeading} / Templates</DropdownMenu.GroupHeading>
|
|
207
|
+
{#each localTemplates as item (item.id)}
|
|
208
|
+
<DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
|
|
209
|
+
{item.name}
|
|
210
|
+
</DropdownMenu.Item>
|
|
211
|
+
{/each}
|
|
212
|
+
</DropdownMenu.Group>
|
|
213
|
+
{/if}
|
|
214
|
+
|
|
215
|
+
{#if globalRecipes.length > 0}
|
|
216
|
+
<DropdownMenu.Separator />
|
|
217
|
+
<DropdownMenu.Group>
|
|
218
|
+
<DropdownMenu.GroupHeading>Recipes</DropdownMenu.GroupHeading>
|
|
219
|
+
{#each globalRecipes as item (item.id)}
|
|
220
|
+
<DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
|
|
221
|
+
{item.name}
|
|
222
|
+
</DropdownMenu.Item>
|
|
223
|
+
{/each}
|
|
224
|
+
</DropdownMenu.Group>
|
|
225
|
+
{/if}
|
|
226
|
+
|
|
227
|
+
{#if localRecipes.length > 0}
|
|
228
|
+
<DropdownMenu.Separator />
|
|
229
|
+
<DropdownMenu.Group>
|
|
230
|
+
<DropdownMenu.GroupHeading>{localTemplateHeading} / Recipes</DropdownMenu.GroupHeading>
|
|
231
|
+
{#each localRecipes as item (item.id)}
|
|
232
|
+
<DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
|
|
233
|
+
{item.name}
|
|
234
|
+
</DropdownMenu.Item>
|
|
235
|
+
{/each}
|
|
236
|
+
</DropdownMenu.Group>
|
|
237
|
+
{/if}
|
|
238
|
+
</DropdownMenu.Content>
|
|
239
|
+
</DropdownMenu.Root>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{#if error}<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>{/if}
|
|
243
|
+
{#if success}<Alert.Root><Alert.Description class="text-success">{success}</Alert.Description></Alert.Root>{/if}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<Panel.Footer>
|
|
247
|
+
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
|
248
|
+
<Button onclick={submit} disabled={!canSubmit}>{submitting ? 'Creating…' : 'Create page'}</Button>
|
|
249
|
+
</Panel.Footer>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create Page feature — workshop form for creating pages inside a prototype.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import CreatePageForm from './CreatePageForm.svelte'
|
|
6
|
+
|
|
7
|
+
export const name = 'createPage'
|
|
8
|
+
export const label = 'Create page'
|
|
9
|
+
export const icon = '📄'
|
|
10
|
+
export const overlayId = 'createPage'
|
|
11
|
+
export const overlay = CreatePageForm
|
|
@@ -27,7 +27,14 @@
|
|
|
27
27
|
let isExternal = $state(false)
|
|
28
28
|
let externalUrl = $state('')
|
|
29
29
|
|
|
30
|
-
interface Partial {
|
|
30
|
+
interface Partial {
|
|
31
|
+
id: string
|
|
32
|
+
name: string
|
|
33
|
+
kind: 'template' | 'recipe'
|
|
34
|
+
scope: 'global' | 'prototype'
|
|
35
|
+
prototype?: string
|
|
36
|
+
folder?: string
|
|
37
|
+
}
|
|
31
38
|
|
|
32
39
|
let folders: string[] = $state([])
|
|
33
40
|
let partials: Partial[] = $state([])
|
|
@@ -61,9 +68,23 @@
|
|
|
61
68
|
})
|
|
62
69
|
const canSubmit = $derived(!!kebabName && !nameError && !submitting && (!isExternal || (!!externalUrl.trim() && !urlError)))
|
|
63
70
|
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
71
|
+
const selectedFolderLabel = $derived(folder ? `${folder}.folder` : '')
|
|
72
|
+
const scopedPartials = $derived(
|
|
73
|
+
partials.filter((p) => {
|
|
74
|
+
if (p.scope === 'global') return true
|
|
75
|
+
if (!folder) return false
|
|
76
|
+
return p.folder === folder
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
const templateLabel = $derived(
|
|
80
|
+
partial ? scopedPartials.find((p) => p.id === partial)?.name ?? partial : 'No template'
|
|
81
|
+
)
|
|
82
|
+
const templates = $derived(scopedPartials.filter((p) => p.kind === 'template'))
|
|
83
|
+
const recipes = $derived(scopedPartials.filter((p) => p.kind === 'recipe'))
|
|
84
|
+
const globalTemplates = $derived(templates.filter((p) => p.scope === 'global'))
|
|
85
|
+
const globalRecipes = $derived(recipes.filter((p) => p.scope === 'global'))
|
|
86
|
+
const localTemplates = $derived(templates.filter((p) => p.scope === 'prototype'))
|
|
87
|
+
const localRecipes = $derived(recipes.filter((p) => p.scope === 'prototype'))
|
|
67
88
|
let templateMenuOpen = $state(false)
|
|
68
89
|
|
|
69
90
|
function getApiUrl() {
|
|
@@ -82,6 +103,14 @@
|
|
|
82
103
|
} catch { /* defaults */ } finally { loading = false }
|
|
83
104
|
})
|
|
84
105
|
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if (!partial) return
|
|
108
|
+
const stillAvailable = scopedPartials.some((p) => p.id === partial)
|
|
109
|
+
if (!stillAvailable) {
|
|
110
|
+
partial = ''
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
85
114
|
function handleTitleInput(e: Event) { title = (e.target as HTMLInputElement).value; titleTouched = true }
|
|
86
115
|
function handleTitleBlur() { if (!title.trim()) titleTouched = false }
|
|
87
116
|
|
|
@@ -182,27 +211,51 @@
|
|
|
182
211
|
{#if templates.length > 0}
|
|
183
212
|
<DropdownMenu.Group>
|
|
184
213
|
<DropdownMenu.GroupHeading>Templates</DropdownMenu.GroupHeading>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
{#each globalTemplates as t (t.id)}
|
|
215
|
+
<DropdownMenu.Item onclick={() => { partial = t.id; templateMenuOpen = false }}>
|
|
216
|
+
{t.name}
|
|
217
|
+
</DropdownMenu.Item>
|
|
218
|
+
{/each}
|
|
219
|
+
</DropdownMenu.Group>
|
|
220
|
+
{/if}
|
|
192
221
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
{#if localTemplates.length > 0}
|
|
223
|
+
<DropdownMenu.Separator />
|
|
224
|
+
<DropdownMenu.Group>
|
|
225
|
+
<DropdownMenu.GroupHeading>{selectedFolderLabel || 'Prototype local'} / Templates</DropdownMenu.GroupHeading>
|
|
226
|
+
{#each localTemplates as t (t.id)}
|
|
227
|
+
<DropdownMenu.Item onclick={() => { partial = t.id; templateMenuOpen = false }}>
|
|
228
|
+
{t.name}
|
|
229
|
+
</DropdownMenu.Item>
|
|
230
|
+
{/each}
|
|
231
|
+
</DropdownMenu.Group>
|
|
232
|
+
{/if}
|
|
233
|
+
|
|
234
|
+
{#if globalRecipes.length > 0}
|
|
235
|
+
<DropdownMenu.Separator />
|
|
236
|
+
<DropdownMenu.Group>
|
|
237
|
+
<DropdownMenu.GroupHeading>Recipes</DropdownMenu.GroupHeading>
|
|
238
|
+
{#each globalRecipes as r (r.id)}
|
|
239
|
+
<DropdownMenu.Item onclick={() => { partial = r.id; templateMenuOpen = false }}>
|
|
240
|
+
{r.name}
|
|
241
|
+
</DropdownMenu.Item>
|
|
242
|
+
{/each}
|
|
243
|
+
</DropdownMenu.Group>
|
|
244
|
+
{/if}
|
|
245
|
+
|
|
246
|
+
{#if localRecipes.length > 0}
|
|
247
|
+
<DropdownMenu.Separator />
|
|
248
|
+
<DropdownMenu.Group>
|
|
249
|
+
<DropdownMenu.GroupHeading>{selectedFolderLabel || 'Prototype local'} / Recipes</DropdownMenu.GroupHeading>
|
|
250
|
+
{#each localRecipes as r (r.id)}
|
|
251
|
+
<DropdownMenu.Item onclick={() => { partial = r.id; templateMenuOpen = false }}>
|
|
252
|
+
{r.name}
|
|
253
|
+
</DropdownMenu.Item>
|
|
254
|
+
{/each}
|
|
255
|
+
</DropdownMenu.Group>
|
|
256
|
+
{/if}
|
|
257
|
+
</DropdownMenu.Content>
|
|
258
|
+
</DropdownMenu.Root>
|
|
206
259
|
</div>
|
|
207
260
|
{/if}
|
|
208
261
|
</div>
|
|
@@ -17,17 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'node:fs'
|
|
19
19
|
import path from 'node:path'
|
|
20
|
+
import {
|
|
21
|
+
buildTemplateRecipeIndex,
|
|
22
|
+
resolveTemplateRecipeEntry,
|
|
23
|
+
} from '../templateIndex.js'
|
|
20
24
|
|
|
21
25
|
const FLOW_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
* Check whether a partial entry is a template (vs recipe).
|
|
25
|
-
* Accepts both singular and plural forms: "template" or "templates".
|
|
26
|
-
*/
|
|
27
|
-
function isTemplate(partialEntry) {
|
|
28
|
-
return partialEntry.directory === 'template' || partialEntry.directory === 'templates'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
// ---------------------------------------------------------------------------
|
|
32
28
|
// Helpers
|
|
33
29
|
// ---------------------------------------------------------------------------
|
|
@@ -120,9 +116,9 @@ function generateBlankIndexJsx(componentName, title) {
|
|
|
120
116
|
* @param {string} title - Human-readable title
|
|
121
117
|
*/
|
|
122
118
|
function generateIndexJsx({ partialEntry, componentFile, componentName, title }) {
|
|
123
|
-
const importPath = `@/${partialEntry.
|
|
119
|
+
const importPath = `@/${partialEntry.baseDir}/${partialEntry.name}/${componentFile}`
|
|
124
120
|
|
|
125
|
-
if (
|
|
121
|
+
if (partialEntry.kind === 'template') {
|
|
126
122
|
return `import ${componentFile} from '${importPath}'
|
|
127
123
|
|
|
128
124
|
export default function ${componentName}() {
|
|
@@ -182,12 +178,14 @@ function generatePrototypeJson({ title, author, description, partialEntry, url }
|
|
|
182
178
|
*/
|
|
183
179
|
export function createPrototypesHandler(ctx) {
|
|
184
180
|
const { root, sendJson, workshopConfig = {} } = ctx
|
|
185
|
-
const
|
|
181
|
+
const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
|
|
186
182
|
|
|
187
183
|
return async (req, res, { body, path: routePath, method }) => {
|
|
184
|
+
const templateRecipes = getTemplateRecipes()
|
|
185
|
+
|
|
188
186
|
if (routePath === '/prototypes' && method === 'GET') {
|
|
189
187
|
const folders = listFolders(root)
|
|
190
|
-
sendJson(res, 200, { folders, partials })
|
|
188
|
+
sendJson(res, 200, { folders, partials: templateRecipes })
|
|
191
189
|
return
|
|
192
190
|
}
|
|
193
191
|
|
|
@@ -282,11 +280,11 @@ export function createPrototypesHandler(ctx) {
|
|
|
282
280
|
|
|
283
281
|
// Look up recipe in config (optional — blank prototype if none)
|
|
284
282
|
const partialEntry = partialName
|
|
285
|
-
?
|
|
283
|
+
? resolveTemplateRecipeEntry(templateRecipes, partialName)
|
|
286
284
|
: null
|
|
287
285
|
|
|
288
286
|
if (partialName && !partialEntry) {
|
|
289
|
-
const validNames =
|
|
287
|
+
const validNames = templateRecipes.map((r) => r.id).join(', ')
|
|
290
288
|
sendJson(res, 400, { error: `Unknown recipe "${partialName}". Available: ${validNames}` })
|
|
291
289
|
return
|
|
292
290
|
}
|
|
@@ -304,10 +302,10 @@ export function createPrototypesHandler(ctx) {
|
|
|
304
302
|
if (!partialEntry) {
|
|
305
303
|
content = generateBlankIndexJsx(componentName, title)
|
|
306
304
|
} else {
|
|
307
|
-
const partialDir = path.join(root, 'src', partialEntry.
|
|
305
|
+
const partialDir = path.join(root, 'src', partialEntry.baseDir, partialEntry.name)
|
|
308
306
|
const componentFile = findComponentFile(partialDir)
|
|
309
307
|
if (!componentFile) {
|
|
310
|
-
sendJson(res, 400, { error: `No .jsx or .tsx file found in src/${partialEntry.
|
|
308
|
+
sendJson(res, 400, { error: `No .jsx or .tsx file found in src/${partialEntry.baseDir}/${partialEntry.name}/` })
|
|
311
309
|
return
|
|
312
310
|
}
|
|
313
311
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import * as createPrototype from './createPrototype/index.js'
|
|
14
14
|
import * as createFlow from './createFlow/index.js'
|
|
15
|
+
import * as createPage from './createPage/index.js'
|
|
15
16
|
import * as createCanvas from './createCanvas/index.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -20,5 +21,6 @@ import * as createCanvas from './createCanvas/index.js'
|
|
|
20
21
|
export const features = {
|
|
21
22
|
createPrototype,
|
|
22
23
|
createFlow,
|
|
24
|
+
createPage,
|
|
23
25
|
createCanvas,
|
|
24
26
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parse as parseJsonc } from 'jsonc-parser'
|
|
4
|
+
|
|
5
|
+
const TEMPLATE_DIR_NAMES = new Set(['template', 'templates'])
|
|
6
|
+
const RECIPE_DIR_NAMES = new Set(['recipe', 'recipes'])
|
|
7
|
+
const PARTIAL_DIR_NAMES = new Set([...TEMPLATE_DIR_NAMES, ...RECIPE_DIR_NAMES])
|
|
8
|
+
|
|
9
|
+
function normalizeSlashes(value) {
|
|
10
|
+
return value.replaceAll('\\', '/')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isExternalPrototype(prototypePath) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(prototypePath, 'utf-8')
|
|
16
|
+
const parsed = parseJsonc(raw)
|
|
17
|
+
return typeof parsed?.url === 'string' && parsed.url.trim().length > 0
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPartialDirName(directory) {
|
|
24
|
+
return PARTIAL_DIR_NAMES.has(directory)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toPartialKind(directory) {
|
|
28
|
+
if (TEMPLATE_DIR_NAMES.has(directory)) return 'template'
|
|
29
|
+
if (RECIPE_DIR_NAMES.has(directory)) return 'recipe'
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listPrototypeDirs(root) {
|
|
34
|
+
const prototypesDir = path.join(root, 'src', 'prototypes')
|
|
35
|
+
if (!fs.existsSync(prototypesDir)) return []
|
|
36
|
+
|
|
37
|
+
const results = []
|
|
38
|
+
|
|
39
|
+
function scanDir(dir, folder) {
|
|
40
|
+
if (!fs.existsSync(dir)) return
|
|
41
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
if (!entry.isDirectory()) continue
|
|
43
|
+
if (entry.name.endsWith('.folder')) {
|
|
44
|
+
scanDir(path.join(dir, entry.name), entry.name.replace(/\.folder$/, ''))
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const protoDir = path.join(dir, entry.name)
|
|
49
|
+
const prototypeFile = fs.readdirSync(protoDir).find((f) => f.endsWith('.prototype.json'))
|
|
50
|
+
if (!prototypeFile) continue
|
|
51
|
+
if (isExternalPrototype(path.join(protoDir, prototypeFile))) continue
|
|
52
|
+
|
|
53
|
+
results.push({
|
|
54
|
+
name: entry.name,
|
|
55
|
+
folder,
|
|
56
|
+
dir: protoDir,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
scanDir(prototypesDir)
|
|
62
|
+
return results
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeId(prefix, directory, name, prototype, folder) {
|
|
66
|
+
if (prefix === 'global') {
|
|
67
|
+
return `global:${directory}/${name}`
|
|
68
|
+
}
|
|
69
|
+
const folderPart = folder ? `${folder}/` : ''
|
|
70
|
+
return `prototype:${folderPart}${prototype}:${directory}/${name}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildTemplateRecipeIndex(root, configPartials = []) {
|
|
74
|
+
const srcRoot = path.join(root, 'src')
|
|
75
|
+
const results = []
|
|
76
|
+
const seenIds = new Set()
|
|
77
|
+
|
|
78
|
+
for (const partial of configPartials) {
|
|
79
|
+
if (!partial || !isPartialDirName(partial.directory) || !partial.name) continue
|
|
80
|
+
const kind = toPartialKind(partial.directory)
|
|
81
|
+
if (!kind) continue
|
|
82
|
+
const id = makeId('global', partial.directory, partial.name)
|
|
83
|
+
if (seenIds.has(id)) continue
|
|
84
|
+
seenIds.add(id)
|
|
85
|
+
results.push({
|
|
86
|
+
id,
|
|
87
|
+
name: partial.name,
|
|
88
|
+
directory: partial.directory,
|
|
89
|
+
kind,
|
|
90
|
+
scope: 'global',
|
|
91
|
+
baseDir: partial.directory,
|
|
92
|
+
globals: Array.isArray(partial.globals) ? partial.globals : undefined,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const prototypes = listPrototypeDirs(root)
|
|
97
|
+
for (const proto of prototypes) {
|
|
98
|
+
for (const directory of PARTIAL_DIR_NAMES) {
|
|
99
|
+
const localPartialsDir = path.join(proto.dir, directory)
|
|
100
|
+
if (!fs.existsSync(localPartialsDir)) continue
|
|
101
|
+
|
|
102
|
+
for (const entry of fs.readdirSync(localPartialsDir, { withFileTypes: true })) {
|
|
103
|
+
if (!entry.isDirectory()) continue
|
|
104
|
+
const kind = toPartialKind(directory)
|
|
105
|
+
if (!kind) continue
|
|
106
|
+
|
|
107
|
+
const baseDir = normalizeSlashes(path.relative(srcRoot, localPartialsDir))
|
|
108
|
+
const id = makeId('prototype', directory, entry.name, proto.name, proto.folder)
|
|
109
|
+
if (seenIds.has(id)) continue
|
|
110
|
+
seenIds.add(id)
|
|
111
|
+
results.push({
|
|
112
|
+
id,
|
|
113
|
+
name: entry.name,
|
|
114
|
+
directory,
|
|
115
|
+
kind,
|
|
116
|
+
scope: 'prototype',
|
|
117
|
+
prototype: proto.name,
|
|
118
|
+
...(proto.folder ? { folder: proto.folder } : {}),
|
|
119
|
+
baseDir,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return results.sort((a, b) => {
|
|
126
|
+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope)
|
|
127
|
+
if ((a.prototype || '') !== (b.prototype || '')) return (a.prototype || '').localeCompare(b.prototype || '')
|
|
128
|
+
if ((a.folder || '') !== (b.folder || '')) return (a.folder || '').localeCompare(b.folder || '')
|
|
129
|
+
if (a.kind !== b.kind) return a.kind.localeCompare(b.kind)
|
|
130
|
+
return a.name.localeCompare(b.name)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveTemplateRecipeEntry(entries, templateValue, { prototype, folder } = {}) {
|
|
135
|
+
if (!templateValue || typeof templateValue !== 'string') return null
|
|
136
|
+
|
|
137
|
+
const trimmed = templateValue.trim()
|
|
138
|
+
if (!trimmed) return null
|
|
139
|
+
|
|
140
|
+
const byId = entries.find((entry) => entry.id === trimmed)
|
|
141
|
+
if (byId) return byId
|
|
142
|
+
|
|
143
|
+
const exactScoped = entries.find((entry) =>
|
|
144
|
+
entry.scope === 'prototype' &&
|
|
145
|
+
entry.name === trimmed &&
|
|
146
|
+
entry.prototype === prototype &&
|
|
147
|
+
(entry.folder || '') === (folder || '')
|
|
148
|
+
)
|
|
149
|
+
if (exactScoped) return exactScoped
|
|
150
|
+
|
|
151
|
+
const globalMatch = entries.find((entry) => entry.scope === 'global' && entry.name === trimmed)
|
|
152
|
+
if (globalMatch) return globalMatch
|
|
153
|
+
|
|
154
|
+
return entries.find((entry) => entry.name === trimmed) || null
|
|
155
|
+
}
|
package/toolbar.config.json
CHANGED
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
{ "type": "header", "label": "Create" },
|
|
79
79
|
{ "id": "workshop/create-prototype", "label": "New prototype", "type": "default", "modes": ["*"], "feature": "createPrototype" },
|
|
80
80
|
{ "id": "workshop/create-flow", "label": "New flow", "type": "default", "modes": ["*"], "feature": "createFlow" },
|
|
81
|
+
{ "id": "workshop/create-page", "label": "New page", "type": "default", "modes": ["*"], "feature": "createPage" },
|
|
81
82
|
{ "id": "workshop/create-canvas", "label": "New canvas", "type": "default", "modes": ["*"], "feature": "createCanvas" },
|
|
82
83
|
{ "type": "footer", "label": "Only available in dev environment" }
|
|
83
84
|
]
|
|
@@ -101,7 +102,7 @@
|
|
|
101
102
|
"modes": ["*"]
|
|
102
103
|
},
|
|
103
104
|
"viewfinder": {
|
|
104
|
-
"label": "
|
|
105
|
+
"label": "Go to index page",
|
|
105
106
|
"render": "link",
|
|
106
107
|
"surface": "command-list",
|
|
107
108
|
"modes": ["*"],
|