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.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- 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/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -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/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- 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/MemberSelect.vue +48 -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/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -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/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -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/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -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/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -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/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -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>
|