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,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>
|