popilot 0.7.0 → 0.8.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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/scaffold/mcp-notification-server/package.json +18 -0
  3. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  4. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  5. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  6. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  7. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  8. package/scaffold/pm-api/sql/003-content.sql +66 -0
  9. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  10. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  11. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  12. package/scaffold/spec-site/package-lock.json +852 -0
  13. package/scaffold/spec-site/package.json +12 -1
  14. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  15. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  16. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  17. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  18. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  19. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  20. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  21. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  22. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  23. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  24. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  25. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  26. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  27. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  28. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  29. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  30. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  31. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  32. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  33. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  34. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  35. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  36. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  37. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  38. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  39. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  40. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  41. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  42. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  43. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  44. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  45. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  46. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  47. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  48. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  49. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  50. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  51. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  52. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  53. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  54. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  55. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  56. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,611 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
4
+ import { useRoute } from 'vue-router'
5
+ import { apiGet, apiPut } from '@/composables/useTurso'
6
+ import ComponentPalette from '@/mockup/ComponentPalette.vue'
7
+ import MockupCanvas from '@/mockup/MockupCanvas.vue'
8
+ import PropertyPanel from '@/mockup/PropertyPanel.vue'
9
+ import { getComponentDef, type ComponentDef } from '@/mockup/componentCatalog'
10
+ import { useScenarios } from '@/mockup/useScenarios'
11
+
12
+ const route = useRoute()
13
+ const slug = computed(() => route.params.slug as string)
14
+
15
+ interface CanvasComponent {
16
+ id: string; componentType: string; props: Record<string, unknown>; children: CanvasComponent[]
17
+ }
18
+
19
+ const components = ref<CanvasComponent[]>([])
20
+ const selectedId = ref<string | null>(null)
21
+ const selectedIds = ref<string[]>([])
22
+
23
+ function onSelect(id: string, event?: MouseEvent) {
24
+ if (event?.shiftKey || event?.metaKey || event?.ctrlKey) {
25
+ // Shift+click: toggle multi-select
26
+ const idx = selectedIds.value.indexOf(id)
27
+ if (idx >= 0) selectedIds.value.splice(idx, 1)
28
+ else selectedIds.value.push(id)
29
+ selectedId.value = id
30
+ } else {
31
+ selectedId.value = id
32
+ selectedIds.value = [id]
33
+ }
34
+ }
35
+
36
+ // Multi-select delete
37
+ function onDeleteMulti() {
38
+ if (selectedIds.value.length > 1) {
39
+ saveUndo()
40
+ for (const id of selectedIds.value) {
41
+ components.value = removeComponent(components.value, id)
42
+ }
43
+ selectedIds.value = []
44
+ selectedId.value = null
45
+ }
46
+ }
47
+
48
+ // Ctrl+C / Ctrl+V
49
+ let clipboard: CanvasComponent[] = []
50
+
51
+ function onCopy() {
52
+ clipboard = selectedIds.value
53
+ .map(id => findComponent(components.value, id))
54
+ .filter(Boolean)
55
+ .map(c => JSON.parse(JSON.stringify(c!)))
56
+ }
57
+
58
+ function onPaste() {
59
+ if (!clipboard.length) return
60
+ saveUndo()
61
+ for (const orig of clipboard) {
62
+ const copy = JSON.parse(JSON.stringify(orig))
63
+ copy.id = `c-${++idCounter}`
64
+ if (typeof copy.props.x === 'number') copy.props.x += 20
65
+ if (typeof copy.props.y === 'number') copy.props.y += 20
66
+ components.value.push(copy)
67
+ }
68
+ }
69
+ const pageTitle = ref('')
70
+ const pageDescription = ref('')
71
+ const viewport = ref<'mobile' | 'desktop'>('desktop')
72
+ const saving = ref(false)
73
+ const hasChanges = ref(false)
74
+ let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
75
+ const zoom = ref(100)
76
+ const showGrid = ref(false)
77
+
78
+ function onWheel(e: WheelEvent) {
79
+ if (!e.ctrlKey && !e.metaKey) return
80
+ e.preventDefault()
81
+ zoom.value = Math.min(400, Math.max(25, zoom.value + (e.deltaY > 0 ? -25 : 25)))
82
+ }
83
+ const saveToast = ref(false)
84
+ let idCounter = 0
85
+
86
+ // Scenarios
87
+ const token = localStorage.getItem('spec-auth-token') || ''
88
+ const { scenarios, activeScenarioId, fetchScenarios, selectScenario, createScenario, deleteScenario } = useScenarios(slug.value, token)
89
+ const showScenarioInput = ref(false)
90
+ const newScenarioName = ref('')
91
+
92
+ async function addScenario() {
93
+ if (!newScenarioName.value.trim()) return
94
+ await createScenario(newScenarioName.value.trim())
95
+ newScenarioName.value = ''
96
+ showScenarioInput.value = false
97
+ }
98
+
99
+ // Custom component management
100
+ const showCompModal = ref(false)
101
+ const customDefs = ref<any[]>([])
102
+ const newDef = ref({ id: '', name: '', category: 'Custom', icon: '🧩', base_type: 'div' })
103
+
104
+ async function fetchCustomDefs() {
105
+ const res = await fetch(`${import.meta.env.VITE_API_URL || ''}/api/v2/mockups/component-defs`, {
106
+ headers: { Authorization: `Bearer ${token}` },
107
+ })
108
+ if (res.ok) { const d = await res.json(); customDefs.value = d.defs || [] }
109
+ }
110
+
111
+ async function addCustomDef() {
112
+ if (!newDef.value.id || !newDef.value.name) return
113
+ await fetch(`${import.meta.env.VITE_API_URL || ''}/api/v2/mockups/component-defs`, {
114
+ method: 'POST',
115
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
116
+ body: JSON.stringify(newDef.value),
117
+ })
118
+ newDef.value = { id: '', name: '', category: 'Custom', icon: '🧩', base_type: 'div' }
119
+ await fetchCustomDefs()
120
+ }
121
+
122
+ async function deleteCustomDef(id: string) {
123
+ await fetch(`${import.meta.env.VITE_API_URL || ''}/api/v2/mockups/component-defs/${id}`, {
124
+ method: 'DELETE',
125
+ headers: { Authorization: `Bearer ${token}` },
126
+ })
127
+ await fetchCustomDefs()
128
+ }
129
+
130
+ // Tree drag & drop (hierarchy change)
131
+ const treeDragOver = ref('')
132
+ let treeDragId = ''
133
+
134
+ function onTreeDragStart(e: DragEvent, id: string) {
135
+ treeDragId = id
136
+ e.dataTransfer?.setData('tree-comp-id', id)
137
+ }
138
+
139
+ function onTreeDragOver(e: DragEvent, target: CanvasComponent) {
140
+ if (treeDragId === target.id) return
141
+ if (getComponentDef(target.componentType)?.allowChildren) {
142
+ treeDragOver.value = target.id
143
+ }
144
+ }
145
+
146
+ function onTreeDrop(e: DragEvent, target: CanvasComponent) {
147
+ treeDragOver.value = ''
148
+ const sourceId = e.dataTransfer?.getData('tree-comp-id') || treeDragId
149
+ if (!sourceId || sourceId === target.id) return
150
+ if (!getComponentDef(target.componentType)?.allowChildren) return
151
+
152
+ saveUndo()
153
+ const comp = findComponent(components.value, sourceId)
154
+ if (!comp) return
155
+ // Remove from source
156
+ components.value = removeComponent(components.value, sourceId)
157
+ // Add to target children
158
+ const parent = findComponent(components.value, target.id)
159
+ if (parent) parent.children.push(comp)
160
+ treeDragId = ''
161
+ }
162
+
163
+ function onTreeDropToRoot(e: DragEvent, childId?: string) {
164
+ treeDragOver.value = ''
165
+ const sourceId = childId || e.dataTransfer?.getData('tree-comp-id') || treeDragId
166
+ if (!sourceId) return
167
+
168
+ saveUndo()
169
+ const comp = findComponent(components.value, sourceId)
170
+ if (!comp) return
171
+ components.value = removeComponent(components.value, sourceId)
172
+ components.value.push(comp)
173
+ treeDragId = ''
174
+ }
175
+
176
+ // Layer z-index operations
177
+ function bringToFront(id: string) {
178
+ const maxZ = Math.max(0, ...components.value.map(c => (c.props.zIndex as number) || 0))
179
+ const comp = findComponent(components.value, id)
180
+ if (comp) comp.props.zIndex = maxZ + 1
181
+ }
182
+
183
+ function sendToBack(id: string) {
184
+ const minZ = Math.min(0, ...components.value.map(c => (c.props.zIndex as number) || 0))
185
+ const comp = findComponent(components.value, id)
186
+ if (comp) comp.props.zIndex = minZ - 1
187
+ }
188
+
189
+ function bringForward(id: string) {
190
+ const comp = findComponent(components.value, id)
191
+ if (comp) comp.props.zIndex = ((comp.props.zIndex as number) || 0) + 1
192
+ }
193
+
194
+ function sendBackward(id: string) {
195
+ const comp = findComponent(components.value, id)
196
+ if (comp) comp.props.zIndex = ((comp.props.zIndex as number) || 0) - 1
197
+ }
198
+
199
+ // Multi-select alignment
200
+ function alignSelected(axis: 'left' | 'right' | 'top' | 'bottom' | 'centerH' | 'centerV') {
201
+ if (selectedIds.value.length < 2) return
202
+ saveUndo()
203
+ const comps = selectedIds.value.map(id => findComponent(components.value, id)).filter(Boolean) as CanvasComponent[]
204
+ const xs = comps.map(c => (c.props.x as number) || 0)
205
+ const ys = comps.map(c => (c.props.y as number) || 0)
206
+ const ws = comps.map(c => (c.props.w as number) || 200)
207
+ const hs = comps.map(c => (c.props.h as number) || 40)
208
+
209
+ if (axis === 'left') { const min = Math.min(...xs); comps.forEach(c => c.props.x = min) }
210
+ if (axis === 'right') { const max = Math.max(...xs.map((x, i) => x + ws[i])); comps.forEach((c, i) => c.props.x = max - ((c.props.w as number) || 200)) }
211
+ if (axis === 'top') { const min = Math.min(...ys); comps.forEach(c => c.props.y = min) }
212
+ if (axis === 'bottom') { const max = Math.max(...ys.map((y, i) => y + hs[i])); comps.forEach((c, i) => c.props.y = max - ((c.props.h as number) || 40)) }
213
+ if (axis === 'centerH') { const avg = xs.reduce((a, x, i) => a + x + ws[i] / 2, 0) / comps.length; comps.forEach((c, i) => c.props.x = Math.round(avg - ((c.props.w as number) || 200) / 2)) }
214
+ if (axis === 'centerV') { const avg = ys.reduce((a, y, i) => a + y + hs[i] / 2, 0) / comps.length; comps.forEach((c, i) => c.props.y = Math.round(avg - ((c.props.h as number) || 40) / 2)) }
215
+ }
216
+
217
+ const selectedComponent = computed(() => {
218
+ if (!selectedId.value) return null
219
+ return findComponent(components.value, selectedId.value)
220
+ })
221
+
222
+ function findComponent(list: CanvasComponent[], id: string): CanvasComponent | null {
223
+ for (const c of list) {
224
+ if (c.id === id) return c
225
+ const found = findComponent(c.children, id)
226
+ if (found) return found
227
+ }
228
+ return null
229
+ }
230
+
231
+ function onEditorKeydown(e: KeyboardEvent) {
232
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { e.preventDefault(); redo(); return }
233
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') { e.preventDefault(); undo() }
234
+ if ((e.metaKey || e.ctrlKey) && e.key === 'y') { e.preventDefault(); redo() }
235
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); onCopy() }
236
+ if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); onPaste() }
237
+ if (e.key === 'Delete' && selectedIds.value.length > 1) { e.preventDefault(); onDeleteMulti() }
238
+ if ((e.metaKey || e.ctrlKey) && e.key === '0') { e.preventDefault(); zoom.value = 100 }
239
+ if ((e.metaKey || e.ctrlKey) && e.key === '1') { e.preventDefault(); zoom.value = 100 }
240
+ }
241
+
242
+ // Auto-save (2s debounce)
243
+ watch(components, () => {
244
+ hasChanges.value = true
245
+ if (autoSaveTimer) clearTimeout(autoSaveTimer)
246
+ autoSaveTimer = setTimeout(() => { save() }, 2000)
247
+ }, { deep: true })
248
+
249
+ onMounted(async () => {
250
+ document.addEventListener('keydown', onEditorKeydown)
251
+ fetchScenarios()
252
+ fetchCustomDefs()
253
+ if (slug.value && slug.value !== 'new') {
254
+ const { data } = await apiGet(`/api/v2/mockups/${slug.value}`)
255
+ if (data?.page) {
256
+ pageTitle.value = (data.page as any).title || ''
257
+ viewport.value = (data.page as any).viewport === 'mobile' ? 'mobile' : 'desktop'
258
+ }
259
+ if (data?.components) {
260
+ // flat -> tree conversion
261
+ const flat = data.components as any[]
262
+ components.value = buildTree(flat)
263
+ }
264
+ }
265
+ })
266
+
267
+ function buildTree(flat: any[]): CanvasComponent[] {
268
+ const map = new Map<number, CanvasComponent>()
269
+ const roots: CanvasComponent[] = []
270
+ for (const f of flat) {
271
+ const comp: CanvasComponent = {
272
+ id: `c-${f.id}`,
273
+ componentType: f.component_type,
274
+ props: JSON.parse(f.props || '{}'),
275
+ children: [],
276
+ }
277
+ map.set(f.id, comp)
278
+ idCounter = Math.max(idCounter, f.id + 1)
279
+ }
280
+ for (const f of flat) {
281
+ const comp = map.get(f.id)!
282
+ if (f.parent_id && map.has(f.parent_id)) {
283
+ map.get(f.parent_id)!.children.push(comp)
284
+ } else {
285
+ roots.push(comp)
286
+ }
287
+ }
288
+ return roots
289
+ }
290
+
291
+ function onAddComponent(def: ComponentDef) {
292
+ components.value.push({
293
+ id: `c-${++idCounter}`,
294
+ componentType: def.id,
295
+ props: { ...def.defaultProps },
296
+ children: [],
297
+ })
298
+ }
299
+
300
+ function onDrop(componentId: string, parentId?: string, x?: number, y?: number) {
301
+ saveUndo()
302
+ const def = getComponentDef(componentId)
303
+ if (!def) return
304
+ const newComp: CanvasComponent = {
305
+ id: `c-${++idCounter}`,
306
+ componentType: def.id,
307
+ props: { ...def.defaultProps, x: x ?? 0, y: y ?? 0 },
308
+ children: [],
309
+ }
310
+ if (parentId) {
311
+ const parent = findComponent(components.value, parentId)
312
+ if (parent && getComponentDef(parent.componentType)?.allowChildren) {
313
+ parent.children.push(newComp)
314
+ return
315
+ }
316
+ }
317
+ components.value.push(newComp)
318
+ }
319
+
320
+ // Undo
321
+ const undoStack = ref<string[]>([])
322
+ const redoStack = ref<string[]>([])
323
+
324
+ function saveUndo() {
325
+ undoStack.value.push(JSON.stringify(components.value))
326
+ if (undoStack.value.length > 20) undoStack.value.shift()
327
+ redoStack.value = []
328
+ }
329
+
330
+ function undo() {
331
+ const prev = undoStack.value.pop()
332
+ if (prev) {
333
+ redoStack.value.push(JSON.stringify(components.value))
334
+ components.value = JSON.parse(prev)
335
+ }
336
+ }
337
+
338
+ function redo() {
339
+ const next = redoStack.value.pop()
340
+ if (next) {
341
+ undoStack.value.push(JSON.stringify(components.value))
342
+ components.value = JSON.parse(next)
343
+ }
344
+ }
345
+
346
+ function onDelete(id: string) {
347
+ if (!confirm('Are you sure you want to delete this?')) return
348
+ saveUndo()
349
+ components.value = removeComponent(components.value, id)
350
+ if (selectedId.value === id) selectedId.value = null
351
+ }
352
+
353
+ function onReorder(id: string, direction: 'up' | 'down') {
354
+ saveUndo()
355
+ const idx = components.value.findIndex(c => c.id === id)
356
+ if (idx < 0) return
357
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1
358
+ if (newIdx < 0 || newIdx >= components.value.length) return
359
+ const temp = components.value[idx]
360
+ components.value[idx] = components.value[newIdx]
361
+ components.value[newIdx] = temp
362
+ components.value = [...components.value]
363
+ }
364
+
365
+ function removeComponent(list: CanvasComponent[], id: string): CanvasComponent[] {
366
+ return list.filter(c => {
367
+ if (c.id === id) return false
368
+ c.children = removeComponent(c.children, id)
369
+ return true
370
+ })
371
+ }
372
+
373
+ function onUpdateProp(key: string, value: unknown) {
374
+ const comp = selectedComponent.value
375
+ if (comp) comp.props[key] = value
376
+ }
377
+
378
+ function onUpdateSpec(desc: string) {
379
+ const comp = selectedComponent.value
380
+ if (comp) comp.props.specDescription = desc
381
+ }
382
+
383
+ // Save
384
+ async function save() {
385
+ saving.value = true
386
+ const flatComps = flattenTree(components.value, null, 0)
387
+ await apiPut(`/api/v2/mockups/${slug.value}`, {
388
+ title: pageTitle.value,
389
+ components: flatComps,
390
+ })
391
+ saving.value = false
392
+ hasChanges.value = false
393
+ saveToast.value = true
394
+ setTimeout(() => { saveToast.value = false }, 2000)
395
+ }
396
+
397
+ function flattenTree(list: CanvasComponent[], parentIdx: number | null, startOrder: number): any[] {
398
+ const result: any[] = []
399
+ let order = startOrder
400
+ for (const c of list) {
401
+ const idx = result.length
402
+ result.push({
403
+ componentType: c.componentType,
404
+ props: c.props,
405
+ parentId: parentIdx,
406
+ specDescription: c.props.specDescription || null,
407
+ sortOrder: order++,
408
+ })
409
+ if (c.children.length) {
410
+ result.push(...flattenTree(c.children, idx, order))
411
+ order += c.children.length
412
+ }
413
+ }
414
+ return result
415
+ }
416
+
417
+ onUnmounted(() => document.removeEventListener("keydown", onEditorKeydown))
418
+ </script>
419
+
420
+ <template>
421
+ <div class="mobile-notice">The mockup editor is only supported on desktop.</div>
422
+ <div class="editor-layout">
423
+ <!-- Left: palette + tree -->
424
+ <div class="editor-left">
425
+ <div class="palette-header">
426
+ <span class="palette-title">Components</span>
427
+ <button class="palette-manage-btn" @click="showCompModal = true; fetchCustomDefs()">Manage</button>
428
+ </div>
429
+ <ComponentPalette @add="onAddComponent" />
430
+ <div class="tree-view">
431
+ <div class="tree-title">Layers</div>
432
+ <div v-for="c in components" :key="c.id"
433
+ class="tree-node" :class="{ 'tree-selected': selectedId === c.id, 'tree-drop-target': treeDragOver === c.id }"
434
+ draggable="true"
435
+ @click="selectedId = c.id"
436
+ @dragstart="onTreeDragStart($event, c.id)"
437
+ @dragover.prevent="onTreeDragOver($event, c)"
438
+ @dragleave="treeDragOver = ''"
439
+ @drop.prevent="onTreeDrop($event, c)">
440
+ {{ getComponentDef(c.componentType)?.icon }} {{ getComponentDef(c.componentType)?.name }}
441
+ <span class="layer-z">z:{{ c.props.zIndex || 0 }}</span>
442
+ <button class="layer-btn" @click.stop="c.props.locked = !c.props.locked" :title="c.props.locked ? 'Unlock' : 'Lock'">{{ c.props.locked ? '<Icon name="unlock" :size="14" />' : '<Icon name="lock" :size="14" />' }}</button>
443
+ <div v-if="selectedId === c.id" class="layer-controls">
444
+ <button class="layer-btn" @click.stop="bringToFront(c.id)" title="Bring to front">⬆⬆</button>
445
+ <button class="layer-btn" @click.stop="bringForward(c.id)" title="Bring forward">⬆</button>
446
+ <button class="layer-btn" @click.stop="sendBackward(c.id)" title="Send backward">⬇</button>
447
+ <button class="layer-btn" @click.stop="sendToBack(c.id)" title="Send to back">⬇⬇</button>
448
+ </div>
449
+ <div v-for="child in c.children" :key="child.id"
450
+ class="tree-child" :class="{ 'tree-selected': selectedId === child.id }"
451
+ draggable="true"
452
+ @click.stop="selectedId = child.id"
453
+ @dragstart.stop="onTreeDragStart($event, child.id)"
454
+ @dragover.prevent.stop
455
+ @drop.prevent.stop="onTreeDrop($event, c)">
456
+ └ {{ getComponentDef(child.componentType)?.icon }} {{ getComponentDef(child.componentType)?.name }}
457
+ </div>
458
+ </div>
459
+ <!-- Root drop zone -->
460
+ <div class="tree-root-drop" @dragover.prevent @drop.prevent="onTreeDropToRoot($event)">Move to root</div>
461
+ </div>
462
+ </div>
463
+
464
+ <!-- Center canvas -->
465
+ <div class="editor-center">
466
+ <!-- Scenario tabs -->
467
+ <div class="scenario-bar">
468
+ <button class="scenario-tab" :class="{ active: !activeScenarioId }" @click="selectScenario(null)">Default</button>
469
+ <button v-for="s in scenarios" :key="s.id" class="scenario-tab" :class="{ active: activeScenarioId === s.id }" @click="selectScenario(s.id)">
470
+ {{ s.name }}
471
+ <span class="scenario-del" @click.stop="deleteScenario(s.id)">✕</span>
472
+ </button>
473
+ <button v-if="!showScenarioInput" class="scenario-add" @click="showScenarioInput = true">+</button>
474
+ <input v-else v-model="newScenarioName" class="scenario-input" placeholder="Scenario name" @keyup.enter="addScenario" @blur="showScenarioInput = false" autofocus />
475
+ </div>
476
+ <div class="editor-toolbar">
477
+ <input v-model="pageTitle" class="toolbar-title" placeholder="Mockup title" />
478
+ <select v-model="viewport" class="toolbar-select">
479
+ <option value="desktop">Desktop</option>
480
+ <option value="mobile">Mobile</option>
481
+ </select>
482
+ <span class="save-indicator" :class="{ changed: hasChanges }">{{ saving ? 'Saving...' : hasChanges ? 'Unsaved changes' : 'Saved' }}</span>
483
+ <button class="styled-btn styled-btn--primary" :disabled="saving" @click="save">{{ saving ? 'Saving...' : 'Save' }}</button>
484
+ <button class="styled-btn styled-btn--ghost" @click="$router.push(`/mockup-viewer/${slug}`)">Preview</button>
485
+ <template v-if="selectedIds.length > 1">
486
+ <button class="align-btn" @click="alignSelected('left')" title="Align left">⬅</button>
487
+ <button class="align-btn" @click="alignSelected('centerH')" title="Center horizontally">↔</button>
488
+ <button class="align-btn" @click="alignSelected('right')" title="Align right">➡</button>
489
+ <button class="align-btn" @click="alignSelected('top')" title="Align top">⬆</button>
490
+ <button class="align-btn" @click="alignSelected('centerV')" title="Center vertically">↕</button>
491
+ <button class="align-btn" @click="alignSelected('bottom')" title="Align bottom">⬇</button>
492
+ </template>
493
+ <button class="styled-btn styled-btn--ghost" @click="showGrid = !showGrid">{{ showGrid ? 'Grid off' : 'Grid' }}</button>
494
+ <span class="zoom-info">{{ zoom }}%</span>
495
+ <input type="range" min="25" max="400" step="25" v-model.number="zoom" class="zoom-slider" />
496
+ </div>
497
+ <div :class="{ 'canvas-grid': showGrid }" :style="{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left' }" @wheel="onWheel">
498
+ <MockupCanvas
499
+ :components="components"
500
+ :selected-id="selectedId"
501
+ :selected-ids="selectedIds"
502
+ :viewport="viewport"
503
+ @select="(id: string, ev: MouseEvent) => onSelect(id, ev)"
504
+ @drop="onDrop"
505
+ @delete="onDelete"
506
+ @reorder="onReorder"
507
+ />
508
+ </div>
509
+ </div>
510
+
511
+ <!-- Right: property panel -->
512
+ <PropertyPanel
513
+ :selected="selectedComponent"
514
+ :page-title="pageTitle"
515
+ :page-description="pageDescription"
516
+ @update-prop="onUpdateProp"
517
+ @update-spec="onUpdateSpec"
518
+ @update-page-title="pageTitle = $event"
519
+ @update-page-desc="pageDescription = $event"
520
+ />
521
+ <!-- Status bar -->
522
+ <div class="editor-statusbar">
523
+ <span>Components: {{ components.length }}</span>
524
+ <span v-if="selectedIds.length > 1">Selected: {{ selectedIds.length }}</span>
525
+ </div>
526
+ <!-- Save toast -->
527
+ <div v-if="saveToast" class="save-toast">Saved</div>
528
+
529
+ <!-- Custom component management modal -->
530
+ <div v-if="showCompModal" class="modal-overlay" @click.self="showCompModal = false">
531
+ <div class="modal-box">
532
+ <div class="modal-header">
533
+ <h3>Manage Components</h3>
534
+ <button class="modal-close" @click="showCompModal = false">✕</button>
535
+ </div>
536
+ <div class="modal-body">
537
+ <div v-for="d in customDefs" :key="d.id" class="custom-def-row">
538
+ <span>{{ d.icon }} {{ d.name }} ({{ d.category }})</span>
539
+ <button v-if="!d.is_builtin" class="css-remove" @click="deleteCustomDef(d.id)">Delete</button>
540
+ </div>
541
+ <div class="custom-def-form">
542
+ <input v-model="newDef.id" placeholder="ID (alphanumeric)" class="prop-input" />
543
+ <input v-model="newDef.name" placeholder="Name" class="prop-input" />
544
+ <input v-model="newDef.icon" placeholder="Icon" class="prop-input" style="width:40px" />
545
+ <input v-model="newDef.category" placeholder="Category" class="prop-input" />
546
+ <button class="css-add-btn" @click="addCustomDef">Add</button>
547
+ </div>
548
+ </div>
549
+ </div>
550
+ </div>
551
+ </div>
552
+ </template>
553
+
554
+ <style scoped>
555
+ .editor-layout { display: flex; height: calc(100vh - 60px); }
556
+ .editor-center { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
557
+ .editor-toolbar { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border, #e5e7eb); align-items: center; }
558
+ .toolbar-title { flex: 1; border: none; font-size: 16px; font-weight: 600; outline: none; }
559
+ .toolbar-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 12px; }
560
+ .styled-btn { border: none; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
561
+ .styled-btn--primary { background: #3b82f6; color: #fff; }
562
+ .styled-btn--primary:hover { background: #2563eb; }
563
+ .styled-btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
564
+ .editor-left { display: flex; flex-direction: column; width: 220px; border-right: 1px solid var(--border, #e5e7eb); }
565
+ .tree-view { padding: 8px 12px; border-top: 1px solid var(--border, #e5e7eb); overflow-y: auto; }
566
+ .tree-title { font-size: 11px; font-weight: 700; color: #9ca3af; margin-bottom: 4px; }
567
+ .tree-node { font-size: 12px; padding: 3px 4px; border-radius: 4px; cursor: pointer; }
568
+ .tree-node:hover { background: #f3f4f6; }
569
+ .tree-child { padding-left: 12px; font-size: 11px; color: #6b7280; }
570
+ .tree-selected { background: #eff6ff; color: #3b82f6; }
571
+ .tree-drop-target { background: #dbeafe; border: 1px dashed #3b82f6; }
572
+ .layer-z { font-size: 9px; color: #9ca3af; margin-left: auto; }
573
+ .layer-controls { display: flex; gap: 2px; margin-top: 2px; }
574
+ .layer-btn { border: none; background: #f3f4f6; border-radius: 3px; padding: 1px 4px; font-size: 10px; cursor: pointer; }
575
+ .layer-btn:hover { background: #e5e7eb; }
576
+ .align-btn { border: none; background: #f3f4f6; border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; }
577
+ .align-btn:hover { background: #e5e7eb; }
578
+ .save-indicator { font-size: 11px; color: #9ca3af; }
579
+ .save-indicator.changed { color: #f59e0b; }
580
+ .editor-statusbar { display: flex; gap: 16px; padding: 4px 16px; border-top: 1px solid var(--border, #e5e7eb); font-size: 11px; color: #9ca3af; }
581
+ .zoom-info { font-size: 11px; color: #6b7280; }
582
+ .zoom-slider { width: 80px; }
583
+ .canvas-grid { background-image: radial-gradient(circle, #d1d5db 1px, transparent 1px); background-size: 10px 10px; }
584
+ .tree-root-drop { padding: 6px 4px; margin-top: 4px; text-align: center; font-size: 10px; color: #9ca3af; border: 1px dashed #d1d5db; border-radius: 4px; }
585
+ .save-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: #22c55e; color: #fff; padding: 8px 24px; border-radius: 8px; font-size: 14px; font-weight: 600; z-index: 9999; }
586
+ .mobile-notice { display: none; }
587
+ .scenario-bar { display: flex; gap: 4px; padding: 4px 16px; border-bottom: 1px solid var(--border, #e5e7eb); overflow-x: auto; align-items: center; }
588
+ .scenario-tab { border: none; background: none; padding: 4px 10px; font-size: 12px; color: #6b7280; cursor: pointer; border-radius: 4px; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
589
+ .scenario-tab.active { background: #3b82f6; color: #fff; }
590
+ .scenario-tab:hover:not(.active) { background: #f3f4f6; }
591
+ .scenario-del { font-size: 10px; opacity: 0.5; }
592
+ .scenario-del:hover { opacity: 1; }
593
+ .scenario-add { border: 1px dashed #d1d5db; background: none; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 14px; color: #9ca3af; }
594
+ .scenario-input { border: 1px solid #3b82f6; border-radius: 4px; padding: 2px 6px; font-size: 12px; width: 100px; }
595
+ .palette-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; }
596
+ .palette-title { font-size: 12px; font-weight: 700; color: #374151; }
597
+ .palette-manage-btn { border: 1px solid #d1d5db; background: #fff; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; color: #6b7280; }
598
+ .palette-manage-btn:hover { background: #f3f4f6; }
599
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 9999; display: flex; align-items: center; justify-content: center; }
600
+ .modal-box { background: #fff; border-radius: 12px; width: 400px; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
601
+ .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #e5e7eb; }
602
+ .modal-header h3 { font-size: 16px; font-weight: 700; margin: 0; }
603
+ .modal-close { border: none; background: none; font-size: 18px; cursor: pointer; color: #6b7280; }
604
+ .modal-body { padding: 16px; }
605
+ .custom-def-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #f3f4f6; font-size: 13px; }
606
+ .custom-def-form { display: flex; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
607
+ .custom-def-form .prop-input { flex: 1; min-width: 60px; border: 1px solid #d1d5db; border-radius: 4px; padding: 4px 6px; font-size: 12px; }
608
+ .css-add-btn { border: none; background: #3b82f6; color: #fff; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; }
609
+ .css-remove { border: none; background: none; color: #ef4444; font-size: 12px; cursor: pointer; }
610
+ @media (max-width: 768px) { .editor-layout { display: none !important; } .mobile-notice { display: block; text-align: center; padding: 40px; color: #6b7280; } }
611
+ </style>