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.
- package/package.json +1 -1
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/spec-site/package-lock.json +852 -0
- package/scaffold/spec-site/package.json +12 -1
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- 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>
|