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,217 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { getComponentDef } from './componentCatalog'
4
+ interface CanvasComponent { id: string; componentType: string; props: Record<string, unknown>; children: CanvasComponent[] }
5
+
6
+ const props = defineProps<{
7
+ selected: CanvasComponent | null
8
+ pageTitle: string
9
+ pageDescription: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update-prop': [key: string, value: unknown]
14
+ 'update-spec': [desc: string]
15
+ 'update-page-title': [title: string]
16
+ 'update-page-desc': [desc: string]
17
+ }>()
18
+
19
+ const CSS_WHITELIST = [
20
+ 'backgroundColor', 'color', 'fontSize', 'fontWeight',
21
+ 'borderRadius', 'border', 'borderColor',
22
+ 'padding', 'margin', 'gap', 'opacity', 'boxShadow',
23
+ 'width', 'height', 'minWidth', 'minHeight', 'maxWidth',
24
+ 'textAlign', 'display', 'flexDirection', 'justifyContent', 'alignItems', 'overflow',
25
+ ]
26
+
27
+ const LAYOUT_CSS = ['display', 'flexDirection', 'justifyContent', 'alignItems', 'gap', 'overflow']
28
+ const TEXT_CSS = ['textAlign', 'fontSize', 'fontWeight', 'color']
29
+
30
+ function getRelevantCss(): string[] {
31
+ const type = props.selected?.componentType || ''
32
+ const def = getComponentDef(type)
33
+ if (def?.allowChildren) return CSS_WHITELIST // Container: all properties
34
+ if (['button', 'text', 'page-title', 'label'].includes(type)) return [...TEXT_CSS, 'backgroundColor', 'borderRadius', 'border', 'borderColor', 'padding', 'margin', 'opacity', 'boxShadow', 'width', 'height', 'minWidth', 'minHeight', 'maxWidth']
35
+ return CSS_WHITELIST.filter(k => !LAYOUT_CSS.includes(k)) // Default: exclude layout
36
+ }
37
+
38
+ // Show only currently active CSS properties
39
+ const activeCssProps = computed(() => {
40
+ const css = (props.selected?.props.css as Record<string, string>) || {}
41
+ return Object.keys(css).filter(k => CSS_WHITELIST.includes(k))
42
+ })
43
+
44
+ const availableCssProps = computed(() => {
45
+ const active = new Set(activeCssProps.value)
46
+ return getRelevantCss().filter(k => !active.has(k))
47
+ })
48
+
49
+ const addCssPropKey = ref('')
50
+
51
+ function addCssProp() {
52
+ if (!addCssPropKey.value) return
53
+ setCssProp(addCssPropKey.value, '')
54
+ addCssPropKey.value = ''
55
+ }
56
+
57
+ function removeCssProp(key: string) {
58
+ const css = { ...((props.selected?.props.css as Record<string, string>) || {}) }
59
+ delete css[key]
60
+ emit('update-prop', 'css', css)
61
+ }
62
+
63
+ function getCssProp(key: string): string {
64
+ const css = (props.selected?.props.css as Record<string, string>) || {}
65
+ return css[key] || ''
66
+ }
67
+
68
+ function setCssProp(key: string, value: string) {
69
+ const css = { ...((props.selected?.props.css as Record<string, string>) || {}), [key]: value }
70
+ if (value === undefined) delete css[key]
71
+ emit('update-prop', 'css', css)
72
+ }
73
+
74
+ function updateMenuItem(idx: number, field: string, value: string) {
75
+ const items = [...((props.selected?.props.menuItems as any[]) || [])]
76
+ items[idx] = { ...items[idx], [field]: value }
77
+ emit('update-prop', 'menuItems', items)
78
+ }
79
+
80
+ function removeMenuItem(idx: number) {
81
+ const items = [...((props.selected?.props.menuItems as any[]) || [])]
82
+ items.splice(idx, 1)
83
+ emit('update-prop', 'menuItems', items)
84
+ }
85
+
86
+ function addMenuItem() {
87
+ const items = [...((props.selected?.props.menuItems as any[]) || []), { text: '', icon: '📄', link: '' }]
88
+ emit('update-prop', 'menuItems', items)
89
+ }
90
+
91
+ const compDef = computed(() => props.selected ? getComponentDef(props.selected.componentType) : null)
92
+
93
+ const SYSTEM_KEYS = ['css', 'menuItems', 'x', 'y', 'w', 'h', 'locked', 'specDescription', 'zIndex', 'onClick']
94
+
95
+ const editableProps = computed(() => {
96
+ if (!props.selected) return []
97
+ const p = props.selected.props
98
+ return Object.entries(p).filter(([key]) => !SYSTEM_KEYS.includes(key)).map(([key, val]) => ({
99
+ key,
100
+ value: val,
101
+ type: typeof val === 'boolean' ? 'boolean'
102
+ : typeof val === 'number' ? 'number'
103
+ : Array.isArray(val) ? 'array'
104
+ : key.includes('color') ? 'color'
105
+ : key.includes('variant') || key.includes('type') || key.includes('level') ? 'select'
106
+ : 'text',
107
+ }))
108
+ })
109
+
110
+ function getSelectOptions(key: string): string[] {
111
+ if (key === 'variant') return ['primary', 'secondary', 'danger', 'ghost']
112
+ if (key === 'type') return ['info', 'warning', 'error', 'success']
113
+ if (key === 'level') return ['h1', 'h2', 'h3']
114
+ if (key === 'direction') return ['column', 'row']
115
+ if (key === 'position') return ['left', 'right']
116
+ if (key === 'size') return ['sm', 'md', 'lg']
117
+ return []
118
+ }
119
+ </script>
120
+
121
+ <template>
122
+ <div class="prop-panel">
123
+ <!-- Component selected -->
124
+ <template v-if="selected">
125
+ <div class="panel-section">
126
+ <div class="panel-title">{{ compDef?.icon }} {{ compDef?.name }}</div>
127
+
128
+ <div v-for="p in editableProps" :key="p.key" class="prop-row">
129
+ <label class="prop-label">{{ p.key }}</label>
130
+
131
+ <input v-if="p.type === 'text'" class="prop-input" :value="p.value" @input="emit('update-prop', p.key, ($event.target as HTMLInputElement).value)" />
132
+ <input v-else-if="p.type === 'number'" class="prop-input" type="number" :value="p.value" @input="emit('update-prop', p.key, Number(($event.target as HTMLInputElement).value))" />
133
+ <input v-else-if="p.type === 'color'" class="prop-color" type="color" :value="p.value" @input="emit('update-prop', p.key, ($event.target as HTMLInputElement).value)" />
134
+ <label v-else-if="p.type === 'boolean'" class="prop-toggle">
135
+ <input type="checkbox" :checked="p.value as boolean" @change="emit('update-prop', p.key, ($event.target as HTMLInputElement).checked)" />
136
+ </label>
137
+ <select v-else-if="p.type === 'select'" class="prop-input" :value="p.value" @change="emit('update-prop', p.key, ($event.target as HTMLSelectElement).value)">
138
+ <option v-for="opt in getSelectOptions(p.key)" :key="opt" :value="opt">{{ opt }}</option>
139
+ </select>
140
+ <div v-else-if="p.key === 'menuItems' && Array.isArray(p.value)" class="menu-editor">
141
+ <div v-for="(item, idx) in (p.value as any[])" :key="idx" class="menu-item-row">
142
+ <input class="prop-input menu-input" :value="item.icon" @input="updateMenuItem(idx, 'icon', ($event.target as HTMLInputElement).value)" style="width:30px" />
143
+ <input class="prop-input menu-input" :value="item.text" @input="updateMenuItem(idx, 'text', ($event.target as HTMLInputElement).value)" placeholder="Name" />
144
+ <input class="prop-input menu-input" :value="item.link" @input="updateMenuItem(idx, 'link', ($event.target as HTMLInputElement).value)" placeholder="Link" style="width:60px" />
145
+ <button class="css-remove" @click="removeMenuItem(idx)">✕</button>
146
+ </div>
147
+ <button class="css-add-btn" @click="addMenuItem" style="margin-top:4px">+ Item</button>
148
+ </div>
149
+ <span v-else-if="p.type === 'array'" class="prop-muted">{{ (p.value as string[]).join(', ') }}</span>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Spec description -->
154
+ <div class="panel-section">
155
+ <div class="panel-subtitle">Spec Description</div>
156
+ <textarea class="spec-textarea" :value="selected.props.specDescription as string || ''" @input="emit('update-spec', ($event.target as HTMLTextAreaElement).value)" placeholder="Role/function of this component..." rows="4" />
157
+ </div>
158
+
159
+ <!-- CSS editing -->
160
+ <div class="panel-section">
161
+ <div class="panel-subtitle">CSS Styles</div>
162
+ <div v-for="cssProp in activeCssProps" :key="cssProp" class="prop-row css-row">
163
+ <label class="prop-label">{{ cssProp }}</label>
164
+ <div class="css-input-wrap">
165
+ <input class="prop-input css-val" :value="getCssProp(cssProp)" @input="setCssProp(cssProp, ($event.target as HTMLInputElement).value)" :type="cssProp.includes('color') || cssProp.includes('Color') ? 'color' : 'text'" />
166
+ <button class="css-remove" @click="removeCssProp(cssProp)" title="Remove">✕</button>
167
+ </div>
168
+ </div>
169
+ <div class="css-add-row">
170
+ <select v-model="addCssPropKey" class="prop-input css-select">
171
+ <option value="">+ Add property</option>
172
+ <option v-for="k in availableCssProps" :key="k" :value="k">{{ k }}</option>
173
+ </select>
174
+ <button v-if="addCssPropKey" class="css-add-btn" @click="addCssProp">Add</button>
175
+ </div>
176
+ </div>
177
+ </template>
178
+
179
+ <!-- No selection -> page meta -->
180
+ <template v-else>
181
+ <div class="panel-section">
182
+ <div class="panel-title">Page Settings</div>
183
+ <div class="prop-row">
184
+ <label class="prop-label">Title</label>
185
+ <input class="prop-input" :value="pageTitle" @input="emit('update-page-title', ($event.target as HTMLInputElement).value)" />
186
+ </div>
187
+ <div class="prop-row">
188
+ <label class="prop-label">Description</label>
189
+ <textarea class="spec-textarea" :value="pageDescription" @input="emit('update-page-desc', ($event.target as HTMLTextAreaElement).value)" rows="4" placeholder="Overall page description..." />
190
+ </div>
191
+ </div>
192
+ </template>
193
+ </div>
194
+ </template>
195
+
196
+ <style scoped>
197
+ .prop-panel { width: 260px; min-width: 260px; border-left: 1px solid var(--border, #e5e7eb); padding: 12px; overflow-y: auto; height: 100%; flex-shrink: 0; }
198
+ .panel-section { margin-bottom: 16px; }
199
+ .panel-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; }
200
+ .panel-subtitle { font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 8px; }
201
+ .prop-row { margin-bottom: 8px; }
202
+ .prop-label { display: block; font-size: 11px; font-weight: 500; color: #6b7280; margin-bottom: 2px; }
203
+ .prop-input { width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 4px 6px; font-size: 12px; box-sizing: border-box; }
204
+ .prop-color { width: 100%; height: 28px; border: 1px solid #d1d5db; border-radius: 4px; cursor: pointer; }
205
+ .prop-muted { font-size: 11px; color: #9ca3af; }
206
+ .spec-textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 6px; font-size: 12px; resize: vertical; box-sizing: border-box; }
207
+ .css-row { position: relative; }
208
+ .css-input-wrap { display: flex; gap: 4px; align-items: center; }
209
+ .css-val { flex: 1; }
210
+ .css-remove { border: none; background: none; color: #ef4444; font-size: 12px; cursor: pointer; padding: 2px; }
211
+ .css-add-row { display: flex; gap: 4px; margin-top: 4px; }
212
+ .css-select { flex: 1; }
213
+ .css-add-btn { border: none; background: #3b82f6; color: #fff; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; }
214
+ .menu-editor { margin-top: 4px; }
215
+ .menu-item-row { display: flex; gap: 2px; align-items: center; margin-bottom: 3px; }
216
+ .menu-input { font-size: 11px; padding: 2px 4px; }
217
+ </style>
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Mockup builder component catalog
3
+ */
4
+
5
+ export interface ComponentDef {
6
+ id: string
7
+ name: string
8
+ category: string
9
+ icon: string
10
+ allowChildren: boolean
11
+ defaultProps: Record<string, unknown>
12
+ }
13
+
14
+ export const COMPONENT_CATALOG: ComponentDef[] = [
15
+ // Layout
16
+ { id: 'container', name: 'Container', category: 'Layout', icon: '📦', allowChildren: true, defaultProps: { direction: 'column', gap: 8, padding: 16 } },
17
+ { id: 'page-wrapper', name: 'PageWrapper', category: 'Layout', icon: '📄', allowChildren: true, defaultProps: { maxWidth: 1200, padding: 24 } },
18
+ { id: 'widget-wrapper', name: 'WidgetWrapper', category: 'Layout', icon: '🧩', allowChildren: true, defaultProps: { title: 'Widget', padding: 16, borderRadius: 12 } },
19
+ { id: 'sidebar', name: 'Sidebar', category: 'Layout', icon: '📐', allowChildren: true, defaultProps: { width: 200, position: 'left', w: 200, h: 400, menuItems: [
20
+ { text: 'Home', icon: '🏠', link: '' },
21
+ { text: 'Dashboard', icon: '📊', link: '' },
22
+ { text: 'Projects', icon: '📦', link: '' },
23
+ { text: 'Reports', icon: '📈', link: '' },
24
+ { text: 'Analytics', icon: '🔍', link: '' },
25
+ { text: 'Settings', icon: '⚙️', link: '' },
26
+ ] } },
27
+ { id: 'row', name: 'Row', category: 'Layout', icon: '↔️', allowChildren: true, defaultProps: { direction: 'row', gap: 8, padding: 0 } },
28
+ { id: 'column', name: 'Column', category: 'Layout', icon: '↕️', allowChildren: true, defaultProps: { direction: 'column', gap: 8, padding: 0 } },
29
+
30
+ // Typography
31
+ { id: 'page-title', name: 'PageTitle', category: 'Typography', icon: '🔤', allowChildren: false, defaultProps: { text: 'Page Title', level: 'h1' } },
32
+ { id: 'text', name: 'Text', category: 'Typography', icon: '📝', allowChildren: false, defaultProps: { text: 'Text content', size: 14, color: '#333' } },
33
+ { id: 'label', name: 'Label', category: 'Typography', icon: '🏷️', allowChildren: false, defaultProps: { text: 'Label', size: 12, color: '#888' } },
34
+ { id: 'hint', name: 'Hint', category: 'Typography', icon: '💡', allowChildren: false, defaultProps: { text: 'Hint text', size: 11, color: '#999' } },
35
+
36
+ // Input
37
+ { id: 'text-field', name: 'TextField', category: 'Input', icon: '✏️', allowChildren: false, defaultProps: { placeholder: 'Enter text...', label: 'Field name', width: '100%' } },
38
+ { id: 'checkbox', name: 'Checkbox', category: 'Input', icon: '☑️', allowChildren: false, defaultProps: { label: 'Checkbox', checked: false } },
39
+ { id: 'radio', name: 'Radio', category: 'Input', icon: '🔘', allowChildren: false, defaultProps: { label: 'Radio', options: ['Option 1', 'Option 2'] } },
40
+ { id: 'date-picker', name: 'DatePicker', category: 'Input', icon: '📅', allowChildren: false, defaultProps: { label: 'Select date', placeholder: 'YYYY-MM-DD' } },
41
+ { id: 'combobox', name: 'Combobox', category: 'Input', icon: '📋', allowChildren: false, defaultProps: { label: 'Select', options: ['Item 1', 'Item 2', 'Item 3'] } },
42
+ { id: 'switch', name: 'SwitchButton', category: 'Input', icon: '🔀', allowChildren: false, defaultProps: { label: 'Switch', checked: false } },
43
+
44
+ // Data
45
+ { id: 'data-grid', name: 'DataGrid', category: 'Data', icon: '📊', allowChildren: false, defaultProps: { columns: ['Name', 'Value', 'Status'], rows: 5, w: 400, h: 300 } },
46
+ { id: 'chart', name: 'Chart', category: 'Data', icon: '📈', allowChildren: false, defaultProps: { type: 'bar', title: 'Chart', height: 200 } },
47
+ { id: 'trend-chart', name: 'TrendChart', category: 'Data', icon: '📉', allowChildren: false, defaultProps: { title: 'Trend', height: 160 } },
48
+
49
+ // Action
50
+ { id: 'button', name: 'Button', category: 'Action', icon: '🔵', allowChildren: false, defaultProps: { text: 'Button', variant: 'primary', size: 'md', onClick: '', w: 120, h: 40 } },
51
+ { id: 'pagination', name: 'Pagination', category: 'Action', icon: '📑', allowChildren: false, defaultProps: { totalPages: 10, current: 1 } },
52
+
53
+ // Feedback
54
+ { id: 'alert', name: 'Alert', category: 'Feedback', icon: '⚠️', allowChildren: false, defaultProps: { message: 'Alert message', type: 'info' } },
55
+ { id: 'loading', name: 'Loading', category: 'Feedback', icon: '⏳', allowChildren: false, defaultProps: { text: 'Loading...' } },
56
+ { id: 'no-data', name: 'NoData', category: 'Feedback', icon: '🚫', allowChildren: false, defaultProps: { message: 'No data' } },
57
+
58
+ // Custom
59
+ { id: 'card', name: 'Card', category: 'Custom', icon: '🃏', allowChildren: true, defaultProps: { title: 'Card', content: 'Enter content here', imageUrl: '', padding: 16, borderRadius: 8, w: 300, h: 200 } },
60
+ { id: 'divider', name: 'Divider', category: 'Custom', icon: '➖', allowChildren: false, defaultProps: { margin: 16 } },
61
+ { id: 'spacer', name: 'Spacer', category: 'Custom', icon: '⬜', allowChildren: false, defaultProps: { height: 24 } },
62
+ ]
63
+
64
+ export const CATEGORIES = [...new Set(COMPONENT_CATALOG.map(c => c.category))]
65
+
66
+ export function getComponentDef(id: string): ComponentDef | undefined {
67
+ return COMPONENT_CATALOG.find(c => c.id === id)
68
+ }
@@ -0,0 +1,67 @@
1
+ import { ref } from 'vue'
2
+
3
+ const API = import.meta.env.VITE_API_URL || ''
4
+
5
+ interface Scenario {
6
+ id: number
7
+ name: string
8
+ description: string
9
+ }
10
+
11
+ interface ScenarioOverride {
12
+ component_id: number
13
+ override_props: string
14
+ }
15
+
16
+ export function useScenarios(slug: string, token: string) {
17
+ const scenarios = ref<Scenario[]>([])
18
+ const activeScenarioId = ref<number | null>(null)
19
+ const overrides = ref<Record<number, Record<string, unknown>>>({})
20
+
21
+ async function fetchScenarios() {
22
+ const res = await fetch(`${API}/api/v2/mockups/${slug}/scenarios`, {
23
+ headers: { Authorization: `Bearer ${token}` },
24
+ })
25
+ if (res.ok) {
26
+ const data = await res.json()
27
+ scenarios.value = data.scenarios || []
28
+ }
29
+ }
30
+
31
+ async function selectScenario(id: number | null) {
32
+ activeScenarioId.value = id
33
+ if (!id) { overrides.value = {}; return }
34
+
35
+ const res = await fetch(`${API}/api/v2/mockups/${slug}/scenarios/${id}/data`, {
36
+ headers: { Authorization: `Bearer ${token}` },
37
+ })
38
+ if (res.ok) {
39
+ const data = await res.json()
40
+ const map: Record<number, Record<string, unknown>> = {}
41
+ for (const ov of (data.overrides || []) as ScenarioOverride[]) {
42
+ map[ov.component_id] = JSON.parse(ov.override_props)
43
+ }
44
+ overrides.value = map
45
+ }
46
+ }
47
+
48
+ async function createScenario(name: string, description = '') {
49
+ await fetch(`${API}/api/v2/mockups/${slug}/scenarios`, {
50
+ method: 'POST',
51
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ name, description }),
53
+ })
54
+ await fetchScenarios()
55
+ }
56
+
57
+ async function deleteScenario(id: number) {
58
+ await fetch(`${API}/api/v2/mockups/${slug}/scenarios/${id}`, {
59
+ method: 'DELETE',
60
+ headers: { Authorization: `Bearer ${token}` },
61
+ })
62
+ if (activeScenarioId.value === id) { activeScenarioId.value = null; overrides.value = {} }
63
+ await fetchScenarios()
64
+ }
65
+
66
+ return { scenarios, activeScenarioId, overrides, fetchScenarios, selectScenario, createScenario, deleteScenario }
67
+ }
@@ -0,0 +1,299 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { apiGet, apiPost, apiPatch, apiDelete, apiPut } from '@/api/client'
4
+
5
+ interface MemberRow {
6
+ id: number
7
+ display_name: string
8
+ email: string | null
9
+ role: string
10
+ is_active: number
11
+ created_at: string
12
+ updated_at: string
13
+ }
14
+
15
+ const members = ref<MemberRow[]>([])
16
+ const loading = ref(true)
17
+ const error = ref('')
18
+
19
+ // New member form
20
+ const newName = ref('')
21
+ const newEmail = ref('')
22
+ const newTtlDays = ref<number | null>(null)
23
+
24
+ // Status message
25
+ const statusMsg = ref('')
26
+
27
+ // LLM settings
28
+ const llmApiKey = ref('')
29
+ const llmProvider = ref('openai')
30
+ const llmModel = ref('')
31
+ const settingsSaved = ref(false)
32
+
33
+ async function loadSettings() {
34
+ const { data } = await apiGet<{ settings: Record<string, string> }>('/api/v2/admin/settings')
35
+ if (data?.settings) {
36
+ llmApiKey.value = data.settings.llm_api_key ?? ''
37
+ llmProvider.value = data.settings.llm_provider ?? 'openai'
38
+ llmModel.value = data.settings.llm_model ?? ''
39
+ }
40
+ }
41
+
42
+ async function saveSettings() {
43
+ settingsSaved.value = false
44
+ await apiPut('/api/v2/admin/settings/llm_api_key', { value: llmApiKey.value || null })
45
+ await apiPut('/api/v2/admin/settings/llm_provider', { value: llmProvider.value || null })
46
+ await apiPut('/api/v2/admin/settings/llm_model', { value: llmModel.value || null })
47
+ settingsSaved.value = true
48
+ setTimeout(() => { settingsSaved.value = false }, 3000)
49
+ }
50
+
51
+ async function clearApiKey() {
52
+ await apiPut('/api/v2/admin/settings/llm_api_key', { value: null })
53
+ llmApiKey.value = ''
54
+ settingsSaved.value = true
55
+ setTimeout(() => { settingsSaved.value = false }, 3000)
56
+ }
57
+
58
+ loadSettings()
59
+
60
+ async function loadMembers() {
61
+ loading.value = true
62
+ const { data, error: apiError } = await apiGet<{ members: MemberRow[] }>('/api/v2/admin/members')
63
+ if (apiError) {
64
+ error.value = apiError
65
+ } else if (data) {
66
+ members.value = data.members
67
+ }
68
+ loading.value = false
69
+ }
70
+
71
+ function generateToken(): string {
72
+ return crypto.randomUUID()
73
+ }
74
+
75
+ async function addMember() {
76
+ const name = newName.value.trim()
77
+ if (!name) return
78
+ const token = generateToken()
79
+ const email = newEmail.value.trim() || null
80
+ const ttl = newTtlDays.value
81
+ const body: Record<string, unknown> = { token, userName: name, userEmail: email }
82
+ if (ttl && Number.isInteger(ttl) && ttl > 0 && ttl <= 3650) body.ttlDays = ttl
83
+ const { error: apiError } = await apiPost('/api/v2/admin/members', body)
84
+ if (apiError) {
85
+ statusMsg.value = `Error: ${apiError}`
86
+ } else {
87
+ statusMsg.value = `${name} added`
88
+ newName.value = ''
89
+ newEmail.value = ''
90
+ newTtlDays.value = null
91
+ await loadMembers()
92
+ }
93
+ clearStatus()
94
+ }
95
+
96
+ async function revokeToken(id: string, name: string) {
97
+ if (!confirm(`Revoke token for ${name}?`)) return
98
+ const { error: apiError } = await apiPatch(`/api/v2/admin/members/${id}/revoke`, {})
99
+ if (!apiError) { statusMsg.value = `${name} token revoked`; await loadMembers() }
100
+ clearStatus()
101
+ }
102
+
103
+ async function reactivateToken(id: string, name: string) {
104
+ const { error: apiError } = await apiPatch(`/api/v2/admin/members/${id}/activate`, {})
105
+ if (!apiError) { statusMsg.value = `${name} token reactivated`; await loadMembers() }
106
+ clearStatus()
107
+ }
108
+
109
+ async function regenerateToken(oldToken: string, name: string) {
110
+ if (!confirm(`Regenerate token for ${name}? The old token will be invalidated.`)) return
111
+ const newToken = generateToken()
112
+ const { error: apiError } = await apiPost(`/api/v2/admin/members/${oldToken}/regenerate`, { newToken })
113
+ if (!apiError) { statusMsg.value = `${name} token regenerated`; await loadMembers() }
114
+ clearStatus()
115
+ }
116
+
117
+ async function deleteMember(id: string, name: string) {
118
+ if (!confirm(`Permanently delete ${name}? This cannot be undone.`)) return
119
+ const { error: apiError } = await apiDelete(`/api/v2/admin/members/${id}`)
120
+ if (!apiError) { statusMsg.value = `${name} deleted`; await loadMembers() }
121
+ clearStatus()
122
+ }
123
+
124
+ function clearStatus() {
125
+ setTimeout(() => { statusMsg.value = '' }, 3000)
126
+ }
127
+
128
+ function formatDate(d: string | null): string {
129
+ if (!d) return '-'
130
+ return d.replace('T', ' ').substring(0, 16)
131
+ }
132
+
133
+ const activeCount = computed(() => members.value.filter(m => m.is_active).length)
134
+ const totalCount = computed(() => members.value.length)
135
+
136
+ onMounted(loadMembers)
137
+ </script>
138
+
139
+ <template>
140
+ <div class="admin">
141
+ <div class="admin-header">
142
+ <div class="admin-header-row">
143
+ <div>
144
+ <h1>Team Token Management</h1>
145
+ <p class="admin-subtitle">This page is accessible only to administrators</p>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Status -->
151
+ <Transition name="fade">
152
+ <div v-if="statusMsg" class="admin-status">{{ statusMsg }}</div>
153
+ </Transition>
154
+
155
+ <!-- Stats -->
156
+ <div class="admin-stats">
157
+ <div class="stat">
158
+ <span class="stat-num">{{ activeCount }}</span>
159
+ <span class="stat-label">Active</span>
160
+ </div>
161
+ <div class="stat">
162
+ <span class="stat-num">{{ totalCount }}</span>
163
+ <span class="stat-label">Total</span>
164
+ </div>
165
+ </div>
166
+
167
+ <!-- Add Member -->
168
+ <div class="admin-card">
169
+ <h2>Add Member</h2>
170
+ <div class="add-form">
171
+ <input v-model="newName" class="input" placeholder="Name" />
172
+ <input v-model="newEmail" class="input" placeholder="Email (optional)" />
173
+ <input v-model.number="newTtlDays" class="input input--sm" type="number" placeholder="TTL (days)" min="1" />
174
+ <button class="btn btn--primary" @click="addMember" :disabled="!newName.trim()">Add</button>
175
+ </div>
176
+ <p class="add-hint">Leave TTL empty for unlimited</p>
177
+ </div>
178
+
179
+ <!-- Members Table -->
180
+ <div class="admin-card">
181
+ <h2>Members</h2>
182
+ <div v-if="loading" class="admin-loading">Loading...</div>
183
+ <div v-else-if="error" class="admin-error">{{ error }}</div>
184
+ <div v-else class="table-wrap">
185
+ <table>
186
+ <thead>
187
+ <tr>
188
+ <th>Status</th>
189
+ <th>Name</th>
190
+ <th>Email</th>
191
+ <th>Role</th>
192
+ <th>Created</th>
193
+ <th>Actions</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ <tr v-for="m in members" :key="String(m.id)" :class="{ inactive: !m.is_active }">
198
+ <td>
199
+ <span class="badge" :class="m.is_active ? 'badge--active' : 'badge--revoked'">
200
+ {{ m.is_active ? 'active' : 'revoked' }}
201
+ </span>
202
+ </td>
203
+ <td class="td-name">{{ m.display_name }}</td>
204
+ <td class="td-email">{{ m.email || '-' }}</td>
205
+ <td>{{ m.role }}</td>
206
+ <td class="td-date">{{ formatDate(m.created_at) }}</td>
207
+ <td class="td-actions">
208
+ <button v-if="m.is_active" class="btn btn--sm btn--warn" @click="revokeToken(String(m.id), m.display_name)">Revoke</button>
209
+ <button v-else class="btn btn--sm btn--ok" @click="reactivateToken(String(m.id), m.display_name)">Activate</button>
210
+ <button class="btn btn--sm" @click="regenerateToken(String(m.id), m.display_name)">Regen</button>
211
+ <button class="btn btn--sm btn--danger" @click="deleteMember(String(m.id), m.display_name)">Delete</button>
212
+ </td>
213
+ </tr>
214
+ </tbody>
215
+ </table>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- AI Settings -->
220
+ <div class="admin-section">
221
+ <h2>AI Settings (BYOM)</h2>
222
+ <div class="setting-row">
223
+ <label>API Key</label>
224
+ <input v-model="llmApiKey" type="password" class="setting-input" placeholder="sk-..." />
225
+ </div>
226
+ <div class="setting-row">
227
+ <label>Provider</label>
228
+ <select v-model="llmProvider" class="setting-input">
229
+ <option value="openai">OpenAI</option>
230
+ <option value="anthropic">Anthropic</option>
231
+ <option value="gemini">Gemini</option>
232
+ </select>
233
+ </div>
234
+ <div class="setting-row">
235
+ <label>Model</label>
236
+ <input v-model="llmModel" class="setting-input" placeholder="gpt-4o-mini" />
237
+ </div>
238
+ <div class="setting-actions">
239
+ <button class="btn btn--primary" @click="saveSettings">Save</button>
240
+ <button class="btn btn--danger" @click="clearApiKey">Clear API Key</button>
241
+ <span v-if="settingsSaved" class="save-ok">Saved</span>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ </template>
246
+
247
+ <style scoped>
248
+ .admin { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
249
+ .admin-header { margin-bottom: 24px; }
250
+ .admin-header-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
251
+ .admin-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; margin-bottom: 4px; }
252
+ .admin-subtitle { font-size: 13px; color: #94a3b8; }
253
+ .admin-status { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; margin-bottom: 16px; }
254
+ .admin-stats { display: flex; gap: 16px; margin-bottom: 24px; }
255
+ .stat { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px 24px; display: flex; flex-direction: column; align-items: center; min-width: 80px; }
256
+ .stat-num { font-size: 28px; font-weight: 700; color: #1e293b; }
257
+ .stat-label { font-size: 12px; color: #94a3b8; margin-top: 2px; }
258
+ .admin-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px 24px; margin-bottom: 20px; }
259
+ .admin-card h2 { font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 16px; }
260
+ .add-form { display: flex; gap: 8px; flex-wrap: wrap; }
261
+ .input { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; flex: 1; min-width: 120px; }
262
+ .input--sm { max-width: 100px; flex: none; }
263
+ .input:focus { outline: none; border-color: #3b82f6; }
264
+ .add-hint { font-size: 11px; color: #94a3b8; margin-top: 8px; }
265
+ .btn { padding: 8px 16px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; background: #fff; color: #475569; white-space: nowrap; transition: all 0.15s; }
266
+ .btn:hover { background: #f1f5f9; }
267
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
268
+ .btn--primary { background: #1e293b; color: #fff; border-color: #1e293b; }
269
+ .btn--primary:hover { background: #334155; }
270
+ .btn--sm { padding: 4px 10px; font-size: 11px; }
271
+ .btn--warn { color: #f59e0b; border-color: #fcd34d; }
272
+ .btn--ok { color: #22c55e; border-color: #86efac; }
273
+ .btn--danger { color: #ef4444; border-color: #fca5a5; }
274
+ .table-wrap { overflow-x: auto; }
275
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
276
+ th { text-align: left; padding: 8px 10px; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; }
277
+ td { padding: 10px; border-bottom: 1px solid #f1f5f9; color: #475569; vertical-align: middle; }
278
+ tr.inactive td { opacity: 0.5; }
279
+ tr:hover td { background: #f8fafc; }
280
+ .td-name { font-weight: 600; color: #1e293b; }
281
+ .td-email { font-size: 12px; }
282
+ .td-date { font-size: 11px; color: #94a3b8; white-space: nowrap; }
283
+ .td-actions { display: flex; gap: 4px; flex-wrap: nowrap; }
284
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
285
+ .badge--active { background: #ecfdf5; color: #059669; }
286
+ .badge--revoked { background: #fef2f2; color: #dc2626; }
287
+ .admin-loading, .admin-error { padding: 20px; text-align: center; color: #94a3b8; font-size: 14px; }
288
+ .admin-error { color: #ef4444; }
289
+ .admin-section { margin-top: 32px; padding: 20px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; }
290
+ .admin-section h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
291
+ .setting-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
292
+ .setting-row label { width: 100px; font-size: 13px; font-weight: 500; color: #64748b; flex-shrink: 0; }
293
+ .setting-input { flex: 1; padding: 8px 12px; border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; font-size: 13px; }
294
+ .setting-actions { display: flex; gap: 8px; align-items: center; }
295
+ .save-ok { font-size: 12px; color: #16a34a; }
296
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
297
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
298
+ @media (max-width: 767px) { .admin { padding: 16px; } .add-form { flex-direction: column; } .td-actions { flex-wrap: wrap; } }
299
+ </style>