popilot 0.6.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 (165) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,459 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import { getComponentDef, type ComponentDef } from './componentCatalog'
4
+
5
+ export interface CanvasComponent {
6
+ id: string
7
+ componentType: string
8
+ props: Record<string, unknown>
9
+ children: CanvasComponent[]
10
+ }
11
+
12
+ const props = defineProps<{
13
+ components: CanvasComponent[]
14
+ selectedId: string | null
15
+ viewport: 'mobile' | 'desktop'
16
+ viewMode?: boolean
17
+ }>()
18
+
19
+ const emit = defineEmits<{
20
+ select: [id: string, event: MouseEvent]
21
+ drop: [componentId: string, parentId?: string, x?: number, y?: number]
22
+ delete: [id: string]
23
+ reorder: [id: string, direction: 'up' | 'down']
24
+ }>()
25
+
26
+ // Auto-expand canvas based on component positions
27
+ const canvasMinWidth = computed(() => {
28
+ let maxX = 0
29
+ for (const c of props.components) {
30
+ const x = (c.props.x as number) || 0
31
+ const w = (c.props.w as number) || 200
32
+ maxX = Math.max(maxX, x + w + 40)
33
+ }
34
+ return maxX > 0 ? `${maxX}px` : 'auto'
35
+ })
36
+
37
+ const canvasMinHeight = computed(() => {
38
+ let maxY = 0
39
+ for (const c of props.components) {
40
+ const y = (c.props.y as number) || 0
41
+ const h = (c.props.h as number) || 40
42
+ maxY = Math.max(maxY, y + h + 40)
43
+ }
44
+ return maxY > 400 ? `${maxY}px` : '400px'
45
+ })
46
+
47
+ const canvasWidth = computed(() => props.viewport === 'mobile' ? '375px' : '100%')
48
+
49
+ function onDragOver(e: DragEvent) {
50
+ e.preventDefault()
51
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
52
+ }
53
+
54
+ function onDrop(e: DragEvent, parentId?: string) {
55
+ e.preventDefault()
56
+ e.stopPropagation()
57
+ const compId = e.dataTransfer?.getData('component-id')
58
+ if (compId) {
59
+ // Drop position X/Y
60
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
61
+ const x = Math.round(e.clientX - rect.left)
62
+ const y = Math.round(e.clientY - rect.top)
63
+ emit('drop', compId, parentId, x, y)
64
+ }
65
+ }
66
+
67
+ // Internal drag (reorder)
68
+ const dragInsert = ref('')
69
+ let internalDragId = ''
70
+
71
+ function onInternalDragStart(e: DragEvent, id: string) {
72
+ internalDragId = id
73
+ if (e.dataTransfer) {
74
+ e.dataTransfer.effectAllowed = 'move'
75
+ e.dataTransfer.setData('internal-comp-id', id)
76
+ }
77
+ }
78
+
79
+ function onInternalDragOver(e: DragEvent, targetId: string) {
80
+ e.preventDefault()
81
+ if (!internalDragId || internalDragId === targetId) return
82
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
83
+ const midY = rect.top + rect.height / 2
84
+ dragInsert.value = e.clientY < midY ? targetId + '-top' : targetId + '-bottom'
85
+ }
86
+
87
+ function onInternalDrop(e: DragEvent, targetId: string) {
88
+ e.preventDefault()
89
+ const sourceId = e.dataTransfer?.getData('internal-comp-id') || internalDragId
90
+ if (!sourceId || sourceId === targetId) { dragInsert.value = ''; return }
91
+
92
+ // Drop from palette
93
+ const paletteCompId = e.dataTransfer?.getData('component-id')
94
+ if (paletteCompId) {
95
+ emit('drop', paletteCompId, getComponentDef(targetId)?.allowChildren ? targetId : undefined)
96
+ dragInsert.value = ''
97
+ return
98
+ }
99
+
100
+ // Internal reorder
101
+ const isTop = dragInsert.value.endsWith('-top')
102
+ emit('reorder', sourceId, isTop ? 'up' : 'down')
103
+ dragInsert.value = ''
104
+ internalDragId = ''
105
+ }
106
+
107
+ // Snap guidelines
108
+ const snapLines = ref<{ type: 'h' | 'v'; pos: number }[]>([])
109
+ const SNAP_THRESHOLD = 5
110
+
111
+ function getSnapTargets(excludeId: string): { x: number; y: number; cx: number; cy: number; r: number; b: number }[] {
112
+ return props.components
113
+ .filter(c => c.id !== excludeId && typeof c.props.x === 'number')
114
+ .map(c => {
115
+ const x = c.props.x as number
116
+ const y = c.props.y as number
117
+ const w = (c.props.w as number) || 200
118
+ const h = (c.props.h as number) || 40
119
+ return { x, y, cx: x + w / 2, cy: y + h / 2, r: x + w, b: y + h }
120
+ })
121
+ }
122
+
123
+ function snapPosition(x: number, y: number, w: number, h: number, excludeId: string): { x: number; y: number } {
124
+ const targets = getSnapTargets(excludeId)
125
+ const lines: { type: 'h' | 'v'; pos: number }[] = []
126
+ let sx = x, sy = y
127
+ const cx = x + w / 2, r = x + w
128
+ const cy = y + h / 2, b = y + h
129
+
130
+ for (const t of targets) {
131
+ // Vertical snap (X axis)
132
+ if (Math.abs(x - t.x) < SNAP_THRESHOLD) { sx = t.x; lines.push({ type: 'v', pos: t.x }) }
133
+ else if (Math.abs(r - t.r) < SNAP_THRESHOLD) { sx = t.r - w; lines.push({ type: 'v', pos: t.r }) }
134
+ else if (Math.abs(cx - t.cx) < SNAP_THRESHOLD) { sx = t.cx - w / 2; lines.push({ type: 'v', pos: t.cx }) }
135
+ else if (Math.abs(x - t.r) < SNAP_THRESHOLD) { sx = t.r; lines.push({ type: 'v', pos: t.r }) }
136
+ else if (Math.abs(r - t.x) < SNAP_THRESHOLD) { sx = t.x - w; lines.push({ type: 'v', pos: t.x }) }
137
+
138
+ // Horizontal snap (Y axis)
139
+ if (Math.abs(y - t.y) < SNAP_THRESHOLD) { sy = t.y; lines.push({ type: 'h', pos: t.y }) }
140
+ else if (Math.abs(b - t.b) < SNAP_THRESHOLD) { sy = t.b - h; lines.push({ type: 'h', pos: t.b }) }
141
+ else if (Math.abs(cy - t.cy) < SNAP_THRESHOLD) { sy = t.cy - h / 2; lines.push({ type: 'h', pos: t.cy }) }
142
+ else if (Math.abs(y - t.b) < SNAP_THRESHOLD) { sy = t.b; lines.push({ type: 'h', pos: t.b }) }
143
+ else if (Math.abs(b - t.y) < SNAP_THRESHOLD) { sy = t.y - h; lines.push({ type: 'h', pos: t.y }) }
144
+ }
145
+ snapLines.value = lines
146
+ return { x: sx, y: sy }
147
+ }
148
+
149
+ // Mouse drag movement
150
+ let dragMoveComp: any = null
151
+ let dragStartX = 0
152
+ let dragStartY = 0
153
+ let origX = 0
154
+ let origY = 0
155
+
156
+ function startDragMove(e: MouseEvent, comp: any) {
157
+ if (props.viewMode) return
158
+ // Initialize default values if x/y/w/h are missing
159
+ if (typeof comp.props.x !== 'number') comp.props.x = 0
160
+ if (typeof comp.props.y !== 'number') comp.props.y = 0
161
+ if (typeof comp.props.w !== 'number') comp.props.w = 200
162
+ if (typeof comp.props.h !== 'number') comp.props.h = 40
163
+ dragMoveComp = comp
164
+ dragStartX = e.clientX
165
+ dragStartY = e.clientY
166
+ origX = comp.props.x as number
167
+ origY = comp.props.y as number
168
+ document.addEventListener('mousemove', onDragMove)
169
+ document.addEventListener('mouseup', onDragMoveEnd)
170
+ }
171
+
172
+ function onDragMove(e: MouseEvent) {
173
+ if (!dragMoveComp) return
174
+ const rawX = origX + (e.clientX - dragStartX)
175
+ const rawY = origY + (e.clientY - dragStartY)
176
+ const w = (dragMoveComp.props.w as number) || 200
177
+ const h = (dragMoveComp.props.h as number) || 40
178
+ const snapped = snapPosition(rawX, rawY, w, h, dragMoveComp.id)
179
+ dragMoveComp.props.x = snapped.x
180
+ dragMoveComp.props.y = snapped.y
181
+ }
182
+
183
+ function onDragMoveEnd() {
184
+ dragMoveComp = null
185
+ snapLines.value = []
186
+ document.removeEventListener('mousemove', onDragMove)
187
+ document.removeEventListener('mouseup', onDragMoveEnd)
188
+ }
189
+
190
+ // Resize
191
+ let resizeComp: any = null
192
+ let resizeDir = ''
193
+ let resizeStartX = 0
194
+ let resizeStartY = 0
195
+ let resizeOrigW = 0
196
+ let resizeOrigH = 0
197
+ let resizeOrigX = 0
198
+ let resizeOrigY = 0
199
+
200
+ function startResize(e: MouseEvent, comp: any, dir: string) {
201
+ resizeComp = comp
202
+ resizeDir = dir
203
+ resizeStartX = e.clientX
204
+ resizeStartY = e.clientY
205
+ resizeOrigW = (comp.props.w as number) || 200
206
+ resizeOrigH = (comp.props.h as number) || 40
207
+ resizeOrigX = (comp.props.x as number) || 0
208
+ resizeOrigY = (comp.props.y as number) || 0
209
+ document.addEventListener('mousemove', onResize)
210
+ document.addEventListener('mouseup', onResizeEnd)
211
+ }
212
+
213
+ function onResize(e: MouseEvent) {
214
+ if (!resizeComp) return
215
+ const dx = e.clientX - resizeStartX
216
+ const dy = e.clientY - resizeStartY
217
+
218
+ if (resizeDir.includes('e')) resizeComp.props.w = Math.max(20, resizeOrigW + dx)
219
+ if (resizeDir.includes('w')) { resizeComp.props.w = Math.max(20, resizeOrigW - dx); resizeComp.props.x = resizeOrigX + dx }
220
+ if (resizeDir.includes('s')) resizeComp.props.h = Math.max(20, resizeOrigH + dy)
221
+ if (resizeDir.includes('n')) { resizeComp.props.h = Math.max(20, resizeOrigH - dy); resizeComp.props.y = resizeOrigY + dy }
222
+ }
223
+
224
+ function onResizeEnd() {
225
+ resizeComp = null
226
+ document.removeEventListener('mousemove', onResize)
227
+ document.removeEventListener('mouseup', onResizeEnd)
228
+ }
229
+
230
+ function onInlineEdit(comp: any, key: string, e: Event) {
231
+ const text = (e.target as HTMLElement).textContent || ''
232
+ if (comp.props[key] !== text) comp.props[key] = text
233
+ }
234
+
235
+ function onKeydown(e: KeyboardEvent) {
236
+ if (e.key === 'Delete' && props.selectedId) {
237
+ emit('delete', props.selectedId)
238
+ }
239
+ }
240
+
241
+ function renderStyle(comp: CanvasComponent): Record<string, string> {
242
+ const p = comp.props
243
+ const style: Record<string, string> = {}
244
+
245
+ // Absolute positioning (when x/y are set)
246
+ if (typeof p.x === 'number' && typeof p.y === 'number') {
247
+ style.position = 'absolute'
248
+ style.left = `${p.x}px`
249
+ style.top = `${p.y}px`
250
+ }
251
+ if (typeof p.w === 'number' && p.w > 0) style.width = `${p.w}px`
252
+ if (typeof p.h === 'number' && p.h > 0) style.height = `${p.h}px`
253
+
254
+ if (p.padding) style.padding = `${p.padding}px`
255
+ if (p.gap) style.gap = `${p.gap}px`
256
+ if (p.direction === 'row') style.flexDirection = 'row'
257
+ if (p.direction === 'column') style.flexDirection = 'column'
258
+ if (p.height && typeof p.x !== 'number') style.height = `${p.height}px`
259
+ if (p.width && typeof p.width === 'number' && typeof p.x !== 'number') style.width = `${p.width}px`
260
+ if (p.maxWidth) style.maxWidth = `${p.maxWidth}px`
261
+ if (p.borderRadius) style.borderRadius = `${p.borderRadius}px`
262
+ if (p.margin) style.margin = `${p.margin}px 0`
263
+ if (typeof p.zIndex === 'number') style.zIndex = String(p.zIndex)
264
+
265
+ // CSS whitelist application
266
+ if (p.css && typeof p.css === 'object') {
267
+ const css = p.css as Record<string, string>
268
+ for (const [k, v] of Object.entries(css)) {
269
+ if (v) style[k] = v
270
+ }
271
+ }
272
+
273
+ return style
274
+ }
275
+ </script>
276
+
277
+ <template>
278
+ <div class="canvas-wrap" tabindex="0" @keydown="onKeydown">
279
+ <div class="canvas-scroll-wrapper">
280
+ <div class="canvas" :style="{ maxWidth: canvasWidth, minWidth: canvasMinWidth, minHeight: canvasMinHeight }" @dragover="onDragOver" @drop="onDrop($event)">
281
+ <div v-if="!components.length && !viewMode" class="canvas-empty">Drag components from the palette on the left<br/><small>Shift/Cmd+click for multi-select | Right-click for menu</small></div>
282
+ <div v-if="!components.length" class="canvas-empty" @dragover="onDragOver" @drop="onDrop($event)">
283
+ Drag and drop components to place them
284
+ </div>
285
+
286
+ <template v-for="comp in components" :key="comp.id">
287
+ <div
288
+ class="canvas-comp"
289
+ :class="{ selected: selectedId === comp.id && !viewMode, 'has-children': getComponentDef(comp.componentType)?.allowChildren, 'view-mode': viewMode, 'drag-over-top': dragInsert === comp.id + '-top', 'drag-over-bottom': dragInsert === comp.id + '-bottom' }"
290
+ :style="renderStyle(comp)"
291
+ :draggable="!viewMode"
292
+ @click.stop="emit('select', comp.id, $event)"
293
+ @mousedown.stop="!comp.props.locked && !$event.shiftKey && !$event.metaKey && !$event.ctrlKey && startDragMove($event, comp)"
294
+ @dragstart.stop="onInternalDragStart($event, comp.id)"
295
+ @dragover.stop="onInternalDragOver($event, comp.id)"
296
+ @dragleave.stop="dragInsert = ''"
297
+ @drop.stop="onInternalDrop($event, comp.id)"
298
+ >
299
+ <div v-if="!viewMode" class="comp-label">{{ getComponentDef(comp.componentType)?.icon }} {{ getComponentDef(comp.componentType)?.name }}</div>
300
+
301
+ <!-- Component rendering -->
302
+ <div v-if="comp.componentType === 'page-title'" class="render-title" :contenteditable="!viewMode && selectedId === comp.id" @blur="onInlineEdit(comp, 'text', $event)">{{ comp.props.text }}</div>
303
+ <div v-else-if="comp.componentType === 'text'" class="render-text" :style="{ fontSize: comp.props.size + 'px', color: comp.props.color as string }" :contenteditable="!viewMode && selectedId === comp.id" @blur="onInlineEdit(comp, 'text', $event)">{{ comp.props.text }}</div>
304
+ <div v-else-if="comp.componentType === 'button'" class="render-button" :class="'btn--' + comp.props.variant" :style="{ cursor: comp.props.onClick ? 'pointer' : 'default' }" @click.stop="comp.props.onClick && viewMode && $router?.push(comp.props.onClick as string)">{{ comp.props.text }}</div>
305
+ <input v-else-if="comp.componentType === 'text-field'" class="render-input" :placeholder="comp.props.placeholder as string" disabled />
306
+ <div v-else-if="comp.componentType === 'data-grid'" class="render-grid">
307
+ <div class="grid-header"><span v-for="col in (comp.props.columns as string[])" :key="col">{{ col }}</span></div>
308
+ <div v-for="i in (comp.props.rows as number)" :key="i" class="grid-row"><span v-for="col in (comp.props.columns as string[])" :key="col">—</span></div>
309
+ </div>
310
+ <div v-else-if="comp.componentType === 'chart'" class="render-chart">📊 {{ comp.props.title }}</div>
311
+ <div v-else-if="comp.componentType === 'alert'" class="render-alert" :class="'alert--' + comp.props.type">{{ comp.props.message }}</div>
312
+ <div v-else-if="comp.componentType === 'divider'" class="render-divider" />
313
+ <div v-else-if="comp.componentType === 'spacer'" :style="{ height: comp.props.height + 'px' }" />
314
+ <div v-else-if="comp.componentType === 'label'" class="render-label">{{ comp.props.text }}</div>
315
+ <label v-else-if="comp.componentType === 'checkbox'" class="render-check">
316
+ <input type="checkbox" :checked="comp.props.checked as boolean" disabled /> {{ comp.props.label }}
317
+ </label>
318
+ <div v-else-if="comp.componentType === 'date-picker'" class="render-datepicker">
319
+ <span class="dp-label">{{ comp.props.label }}</span>
320
+ <input type="date" class="dp-input" disabled />
321
+ </div>
322
+ <div v-else-if="comp.componentType === 'pagination'" class="render-pagination">
323
+ <button class="pg-btn" disabled>&lt;</button>
324
+ <button v-for="p in Math.min(comp.props.totalPages as number || 5, 7)" :key="p" class="pg-btn" :class="{ 'pg-active': p === (comp.props.current as number || 1) }" disabled>{{ p }}</button>
325
+ <button class="pg-btn" disabled>&gt;</button>
326
+ </div>
327
+ <div v-else-if="comp.componentType === 'switch'" class="render-switch">
328
+ <span>{{ comp.props.label }}</span>
329
+ <span class="switch-track" :class="{ 'switch-on': comp.props.checked }"><span class="switch-thumb" /></span>
330
+ </div>
331
+ <div v-else-if="comp.componentType === 'card'" class="render-card">
332
+ <img v-if="comp.props.imageUrl" :src="comp.props.imageUrl as string" class="card-image" />
333
+ <div class="card-title">{{ comp.props.title }}</div>
334
+ <div class="card-content">{{ comp.props.content }}</div>
335
+ </div>
336
+ <div v-else-if="comp.componentType === 'sidebar'" class="render-sidebar">
337
+ <div v-for="(item, idx) in (comp.props.menuItems as any[] || [])" :key="idx" class="sidebar-item" @click.stop="item.link && $router?.push(item.link)">
338
+ <span class="sidebar-icon">{{ item.icon }}</span>
339
+ <span class="sidebar-text">{{ item.text }}</span>
340
+ </div>
341
+ </div>
342
+ <div v-else class="render-placeholder">{{ comp.props.text || comp.props.title || comp.componentType }}</div>
343
+
344
+ <!-- Children -->
345
+ <template v-if="comp.children?.length">
346
+ <MockupCanvas
347
+ :components="comp.children"
348
+ :selected-id="selectedId"
349
+ :viewport="viewport"
350
+ :view-mode="viewMode"
351
+ @select="(id: string, ev: MouseEvent) => emit('select', id, ev)"
352
+ @drop="emit('drop', $event, comp.id)"
353
+ @delete="emit('delete', $event)"
354
+ />
355
+ </template>
356
+
357
+ <!-- Lock indicator -->
358
+ <div v-if="comp.props.locked && !viewMode" class="lock-indicator">🔒</div>
359
+ <!-- Resize handles + delete -->
360
+ <template v-if="selectedId === comp.id && !viewMode && !comp.props.locked">
361
+ <div class="resize-handle rh-n" @mousedown.stop.prevent="startResize($event, comp, 'n')" />
362
+ <div class="resize-handle rh-s" @mousedown.stop.prevent="startResize($event, comp, 's')" />
363
+ <div class="resize-handle rh-e" @mousedown.stop.prevent="startResize($event, comp, 'e')" />
364
+ <div class="resize-handle rh-w" @mousedown.stop.prevent="startResize($event, comp, 'w')" />
365
+ <div class="resize-handle rh-ne" @mousedown.stop.prevent="startResize($event, comp, 'ne')" />
366
+ <div class="resize-handle rh-nw" @mousedown.stop.prevent="startResize($event, comp, 'nw')" />
367
+ <div class="resize-handle rh-se" @mousedown.stop.prevent="startResize($event, comp, 'se')" />
368
+ <div class="resize-handle rh-sw" @mousedown.stop.prevent="startResize($event, comp, 'sw')" />
369
+ </template>
370
+ <div v-if="selectedId === comp.id && !viewMode" class="comp-controls">
371
+ <button class="comp-delete" @click.stop="emit('delete', comp.id)">✕</button>
372
+ </div>
373
+ </div>
374
+ </template>
375
+ <!-- Snap lines -->
376
+ <div v-for="(line, idx) in snapLines" :key="idx" class="snap-line" :class="line.type === 'h' ? 'snap-h' : 'snap-v'" :style="line.type === 'h' ? { top: line.pos + 'px' } : { left: line.pos + 'px' }" />
377
+ </div>
378
+ </div>
379
+ </div>
380
+ </template>
381
+
382
+ <style scoped>
383
+ .canvas-wrap { flex: 1; overflow: auto; padding: 16px; background: #f8f9fa; min-height: 300px; }
384
+ .canvas-scroll-wrapper { overflow: auto; flex: 1; }
385
+ .canvas-empty { display: flex; align-items: center; justify-content: center; height: 200px; color: #9ca3af; font-size: 14px; }
386
+ .snap-line { position: absolute; z-index: 9999; pointer-events: none; }
387
+ .snap-h { left: 0; right: 0; height: 1px; background: #f43f5e; }
388
+ .snap-v { top: 0; bottom: 0; width: 1px; background: #f43f5e; }
389
+ .canvas { margin: 0 auto; min-height: 400px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); padding: 16px; display: flex; flex-direction: column; gap: 4px; position: relative; }
390
+ .canvas-empty { display: flex; align-items: center; justify-content: center; height: 200px; color: #9ca3af; font-size: 14px; border: 2px dashed #d1d5db; border-radius: 8px; }
391
+ .canvas-comp { position: relative; border: 1px solid transparent; border-radius: 4px; padding: 0; transition: all 0.1s; display: flex; flex-direction: column; box-sizing: border-box; }
392
+ .canvas-comp > * { flex: 1; }
393
+ .lock-indicator { position: absolute; top: 2px; left: 2px; font-size: 12px; opacity: 0.5; z-index: 5; pointer-events: none; }
394
+ .render-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
395
+ .card-image { width: 100%; height: 120px; object-fit: cover; }
396
+ .card-title { font-size: 14px; font-weight: 700; padding: 8px 12px 4px; }
397
+ .card-content { font-size: 12px; color: #6b7280; padding: 0 12px 12px; line-height: 1.5; }
398
+ .render-sidebar { background: #1e293b; border-radius: 8px; padding: 8px 0; min-height: 200px; width: 100%; height: 100%; box-sizing: border-box; }
399
+ .sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; color: #cbd5e1; font-size: 13px; cursor: pointer; transition: background 0.15s; }
400
+ .sidebar-item:hover { background: rgba(255,255,255,0.1); color: #fff; }
401
+ .sidebar-icon { font-size: 16px; }
402
+ .sidebar-text { font-weight: 500; }
403
+ .canvas-comp:hover { border-color: #93c5fd; }
404
+ .canvas-comp.view-mode { border: none !important; padding: 2px 0; outline: none !important; }
405
+ .canvas-comp.view-mode:hover { border-color: transparent !important; box-shadow: none; }
406
+ .canvas-comp.view-mode { cursor: pointer; }
407
+ .canvas-comp.view-mode.selected { outline: 2px solid rgba(59,130,246,0.3) !important; border-radius: 4px; background: rgba(59,130,246,0.04); }
408
+ .canvas-comp.selected { border-color: #3b82f6; outline: 2px solid rgba(59,130,246,0.3); }
409
+ .canvas-comp.has-children { min-height: 40px; }
410
+ .comp-label { font-size: 10px; color: #9ca3af; margin-bottom: 2px; }
411
+ .canvas-comp.drag-over-top { border-top: 2px solid #3b82f6 !important; }
412
+ .canvas-comp.drag-over-bottom { border-bottom: 2px solid #3b82f6 !important; }
413
+ .resize-handle { position: absolute; width: 8px; height: 8px; background: #3b82f6; border-radius: 50%; z-index: 10; }
414
+ .rh-n { top: -4px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
415
+ .rh-s { bottom: -4px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
416
+ .rh-e { right: -4px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
417
+ .rh-w { left: -4px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
418
+ .rh-ne { top: -4px; right: -4px; cursor: ne-resize; }
419
+ .rh-nw { top: -4px; left: -4px; cursor: nw-resize; }
420
+ .rh-se { bottom: -4px; right: -4px; cursor: se-resize; }
421
+ .rh-sw { bottom: -4px; left: -4px; cursor: sw-resize; }
422
+ .comp-controls { position: absolute; top: -10px; right: -10px; display: flex; gap: 2px; }
423
+ .comp-delete { width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: #fff; border: none; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
424
+
425
+ /* Rendering */
426
+ .render-title { font-size: 24px; font-weight: 700; }
427
+ .render-text { line-height: 1.5; }
428
+ .render-button { display: inline-block; padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; }
429
+ .btn--primary { background: #3b82f6; color: #fff; }
430
+ .btn--secondary { background: #e5e7eb; color: #333; }
431
+ .btn--danger { background: #ef4444; color: #fff; }
432
+ .btn--ghost { background: transparent; color: #3b82f6; border: 1px solid #3b82f6; }
433
+ .render-input { border: 1px solid #d1d5db; border-radius: 6px; padding: 8px; font-size: 13px; width: 100%; box-sizing: border-box; }
434
+ .render-grid { border: 1px solid #e5e7eb; border-radius: 4px; overflow: hidden; font-size: 12px; }
435
+ .grid-header { display: flex; background: #f8f9fa; font-weight: 600; }
436
+ .grid-header span, .grid-row span { flex: 1; padding: 6px 8px; border-right: 1px solid #e5e7eb; }
437
+ .grid-row { display: flex; border-top: 1px solid #e5e7eb; }
438
+ .render-chart { background: #f0f4ff; border-radius: 6px; padding: 24px; text-align: center; color: #6b7280; }
439
+ .render-alert { padding: 8px 12px; border-radius: 6px; font-size: 13px; }
440
+ .alert--info { background: #dbeafe; color: #1d4ed8; }
441
+ .alert--warning { background: #fef3c7; color: #d97706; }
442
+ .alert--error { background: #fef2f2; color: #dc2626; }
443
+ .alert--success { background: #dcfce7; color: #16a34a; }
444
+ .render-divider { height: 1px; background: #e5e7eb; margin: 8px 0; }
445
+ .render-label { font-size: 12px; color: #6b7280; font-weight: 500; }
446
+ .render-check { font-size: 13px; display: flex; align-items: center; gap: 6px; }
447
+ .render-datepicker { display: flex; flex-direction: column; gap: 4px; }
448
+ .dp-label { font-size: 12px; color: #6b7280; }
449
+ .dp-input { border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 8px; font-size: 13px; }
450
+ .render-pagination { display: flex; gap: 4px; }
451
+ .pg-btn { width: 28px; height: 28px; border: 1px solid #d1d5db; border-radius: 4px; background: #fff; font-size: 12px; display: flex; align-items: center; justify-content: center; }
452
+ .pg-active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
453
+ .render-switch { display: flex; align-items: center; gap: 8px; font-size: 13px; }
454
+ .switch-track { width: 36px; height: 20px; background: #d1d5db; border-radius: 10px; position: relative; display: inline-block; }
455
+ .switch-on { background: #3b82f6; }
456
+ .switch-thumb { width: 16px; height: 16px; background: #fff; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: left 0.2s; }
457
+ .switch-on .switch-thumb { left: 18px; }
458
+ .render-placeholder { color: #9ca3af; font-size: 12px; padding: 8px; background: #f9fafb; border-radius: 4px; }
459
+ </style>