popilot 0.5.0 → 0.7.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 (171) hide show
  1. package/adapters/codex/.codex/commands/_domain.md.hbs +33 -0
  2. package/adapters/codex/.codex/commands/analytics.md.hbs +55 -0
  3. package/adapters/codex/.codex/commands/daily.md.hbs +301 -0
  4. package/adapters/codex/.codex/commands/dev.md.hbs +62 -0
  5. package/adapters/codex/.codex/commands/gtm.md +82 -0
  6. package/adapters/codex/.codex/commands/handoff.md +259 -0
  7. package/adapters/codex/.codex/commands/market.md +120 -0
  8. package/adapters/codex/.codex/commands/metrics.md +123 -0
  9. package/adapters/codex/.codex/commands/oscar-loop.md +436 -0
  10. package/adapters/codex/.codex/commands/party.md +85 -0
  11. package/adapters/codex/.codex/commands/plan.md +43 -0
  12. package/adapters/codex/.codex/commands/research.md +203 -0
  13. package/adapters/codex/.codex/commands/retro.md +68 -0
  14. package/adapters/codex/.codex/commands/save.md +440 -0
  15. package/adapters/codex/.codex/commands/sessions.md +139 -0
  16. package/adapters/codex/.codex/commands/sprint.md +106 -0
  17. package/adapters/codex/.codex/commands/start.md +396 -0
  18. package/adapters/codex/.codex/commands/strategy.md +41 -0
  19. package/adapters/codex/.codex/commands/task.md +220 -0
  20. package/adapters/codex/.codex/commands/tracking.md +116 -0
  21. package/adapters/codex/.codex/commands/validate.md +58 -0
  22. package/adapters/codex/AGENTS.md.hbs +210 -0
  23. package/adapters/codex/manifest.yaml +36 -0
  24. package/adapters/gemini/.gemini/commands/_domain.md.hbs +33 -0
  25. package/adapters/gemini/.gemini/commands/analytics.md.hbs +55 -0
  26. package/adapters/gemini/.gemini/commands/daily.md.hbs +301 -0
  27. package/adapters/gemini/.gemini/commands/dev.md.hbs +62 -0
  28. package/adapters/gemini/.gemini/commands/gtm.md +82 -0
  29. package/adapters/gemini/.gemini/commands/handoff.md +259 -0
  30. package/adapters/gemini/.gemini/commands/market.md +120 -0
  31. package/adapters/gemini/.gemini/commands/metrics.md +123 -0
  32. package/adapters/gemini/.gemini/commands/oscar-loop.md +436 -0
  33. package/adapters/gemini/.gemini/commands/party.md +85 -0
  34. package/adapters/gemini/.gemini/commands/plan.md +43 -0
  35. package/adapters/gemini/.gemini/commands/research.md +203 -0
  36. package/adapters/gemini/.gemini/commands/retro.md +68 -0
  37. package/adapters/gemini/.gemini/commands/save.md +440 -0
  38. package/adapters/gemini/.gemini/commands/sessions.md +139 -0
  39. package/adapters/gemini/.gemini/commands/sprint.md +106 -0
  40. package/adapters/gemini/.gemini/commands/start.md +396 -0
  41. package/adapters/gemini/.gemini/commands/strategy.md +41 -0
  42. package/adapters/gemini/.gemini/commands/task.md +220 -0
  43. package/adapters/gemini/.gemini/commands/tracking.md +116 -0
  44. package/adapters/gemini/.gemini/commands/validate.md +58 -0
  45. package/adapters/gemini/GEMINI.md.hbs +210 -0
  46. package/adapters/gemini/manifest.yaml +36 -0
  47. package/bin/cli.mjs +215 -4
  48. package/lib/doctor.mjs +38 -1
  49. package/lib/hydrate.mjs +15 -0
  50. package/lib/industry-presets.mjs +135 -0
  51. package/lib/scaffold.mjs +5 -0
  52. package/lib/setup-wizard.mjs +71 -2
  53. package/package.json +1 -1
  54. package/scaffold/.context/agents/TEMPLATE.md +14 -0
  55. package/scaffold/.context/agents/analyst.md.hbs +3 -3
  56. package/scaffold/.context/agents/developer.md.hbs +5 -5
  57. package/scaffold/.context/agents/gtm-strategist.md.hbs +3 -3
  58. package/scaffold/.context/agents/handoff-specialist.md.hbs +18 -18
  59. package/scaffold/.context/agents/market-researcher.md.hbs +6 -6
  60. package/scaffold/.context/agents/orchestrator.md.hbs +8 -8
  61. package/scaffold/.context/agents/planner.md.hbs +6 -6
  62. package/scaffold/.context/agents/qa.md.hbs +5 -5
  63. package/scaffold/.context/agents/researcher.md.hbs +33 -6
  64. package/scaffold/.context/agents/strategist.md.hbs +8 -8
  65. package/scaffold/.context/agents/tracking-governor.md.hbs +2 -2
  66. package/scaffold/.context/project.yaml.example +25 -0
  67. package/scaffold/mcp-pm/package.json +19 -0
  68. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  69. package/scaffold/mcp-pm/src/index.ts +660 -0
  70. package/scaffold/mcp-pm/tsconfig.json +14 -0
  71. package/scaffold/pm-api/package.json +21 -0
  72. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  73. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  74. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  75. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  76. package/scaffold/pm-api/src/auth.ts +28 -0
  77. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  78. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  79. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  80. package/scaffold/pm-api/src/db/turso.ts +147 -0
  81. package/scaffold/pm-api/src/index.ts +114 -0
  82. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  83. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  84. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  85. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  86. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  87. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  88. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  89. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  90. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  91. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  92. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  93. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  94. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  95. package/scaffold/pm-api/src/mcp.ts +871 -0
  96. package/scaffold/pm-api/src/nudge.ts +283 -0
  97. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  98. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  99. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  100. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  101. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  102. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  103. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  104. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  105. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  106. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  107. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  108. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  109. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  110. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  111. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  112. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  113. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  114. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  115. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  116. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  117. package/scaffold/pm-api/src/types.ts +11 -0
  118. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  119. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  120. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  121. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  122. package/scaffold/pm-api/src/utils/db.ts +45 -0
  123. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  124. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  125. package/scaffold/pm-api/tsconfig.json +15 -0
  126. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  127. package/scaffold/spec-site/package-lock.json +40 -0
  128. package/scaffold/spec-site/package.json +4 -1
  129. package/scaffold/spec-site/src/api/types.ts +6 -0
  130. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  131. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  132. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  133. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  134. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  135. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  136. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  137. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  138. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  139. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  140. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  141. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  142. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  143. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  144. package/scaffold/spec-site/src/features.ts +108 -0
  145. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  146. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  147. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  148. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  149. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  150. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  151. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  152. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  153. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  154. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  155. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  156. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  157. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  158. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  159. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  160. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  161. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  162. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  163. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  164. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  165. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  166. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  167. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  168. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  169. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  170. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  171. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { apiGet, isStaticMode } from '@/api/client'
4
+
5
+ interface DocEntry {
6
+ id: number
7
+ title: string
8
+ path: string
9
+ updated_by: string
10
+ updated_at: string
11
+ children?: DocEntry[]
12
+ }
13
+
14
+ interface DocsData {
15
+ documents: DocEntry[]
16
+ recent_edits: { id: number; title: string; user: string; updated_at: string }[]
17
+ }
18
+
19
+ const loading = ref(true)
20
+ const error = ref<string | null>(null)
21
+ const docs = ref<DocEntry[]>([])
22
+ const recentEdits = ref<DocsData['recent_edits']>([])
23
+ const expandedIds = ref<Set<number>>(new Set())
24
+
25
+ function toggleExpand(id: number) {
26
+ if (expandedIds.value.has(id)) {
27
+ expandedIds.value.delete(id)
28
+ } else {
29
+ expandedIds.value.add(id)
30
+ }
31
+ }
32
+
33
+ onMounted(async () => {
34
+ if (isStaticMode()) { loading.value = false; return }
35
+ const { data, error: err } = await apiGet<DocsData>('/api/v2/docs')
36
+ if (err) {
37
+ error.value = err
38
+ } else if (data) {
39
+ docs.value = data.documents
40
+ recentEdits.value = data.recent_edits
41
+ }
42
+ loading.value = false
43
+ })
44
+ </script>
45
+
46
+ <template>
47
+ <div class="docs-page">
48
+ <h1>Documents</h1>
49
+ <p class="page-desc">Team documentation hub.</p>
50
+
51
+ <div v-if="loading" class="loading-state">
52
+ <div class="loading-spinner" />
53
+ <span>Loading documents...</span>
54
+ </div>
55
+
56
+ <div v-else-if="error" class="error-state">
57
+ <div class="error-icon">&#9888;</div>
58
+ <p>{{ error }}</p>
59
+ </div>
60
+
61
+ <template v-else>
62
+ <div class="docs-layout">
63
+ <!-- Document tree -->
64
+ <div class="docs-tree">
65
+ <h2>All Documents</h2>
66
+ <div v-if="!docs.length" class="empty-msg">No documents yet.</div>
67
+ <ul class="tree-list">
68
+ <li v-for="doc in docs" :key="doc.id" class="tree-item">
69
+ <div class="tree-row" @click="toggleExpand(doc.id)">
70
+ <span class="tree-icon" v-if="doc.children?.length">
71
+ {{ expandedIds.has(doc.id) ? '&#9660;' : '&#9654;' }}
72
+ </span>
73
+ <span class="tree-icon tree-leaf" v-else>&#9679;</span>
74
+ <span class="tree-title">{{ doc.title }}</span>
75
+ <span class="tree-meta">{{ doc.updated_by }}</span>
76
+ </div>
77
+ <ul v-if="doc.children?.length && expandedIds.has(doc.id)" class="tree-children">
78
+ <li v-for="child in doc.children" :key="child.id" class="tree-item tree-child">
79
+ <div class="tree-row">
80
+ <span class="tree-icon tree-leaf">&#9679;</span>
81
+ <span class="tree-title">{{ child.title }}</span>
82
+ <span class="tree-meta">{{ child.updated_by }}</span>
83
+ </div>
84
+ </li>
85
+ </ul>
86
+ </li>
87
+ </ul>
88
+ </div>
89
+
90
+ <!-- Recent edits -->
91
+ <div class="docs-recent">
92
+ <h2>Recent Edits</h2>
93
+ <div v-if="!recentEdits.length" class="empty-msg">No recent edits.</div>
94
+ <ul class="edit-list">
95
+ <li v-for="edit in recentEdits" :key="edit.id" class="edit-item">
96
+ <div class="edit-title">{{ edit.title }}</div>
97
+ <div class="edit-meta">
98
+ <span>{{ edit.user }}</span>
99
+ <span>{{ edit.updated_at }}</span>
100
+ </div>
101
+ </li>
102
+ </ul>
103
+ </div>
104
+ </div>
105
+ </template>
106
+ </div>
107
+ </template>
108
+
109
+ <style scoped>
110
+ .docs-page { padding: 32px 40px; max-width: 960px; margin: 0 auto; }
111
+ h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
112
+ .page-desc { font-size: 13px; color: var(--text-secondary); margin-bottom: 24px; }
113
+
114
+ .loading-state, .error-state {
115
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
116
+ padding: 80px 0; gap: 12px; color: var(--text-muted);
117
+ }
118
+ .loading-spinner {
119
+ width: 28px; height: 28px; border: 3px solid var(--border);
120
+ border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite;
121
+ }
122
+ @keyframes spin { to { transform: rotate(360deg); } }
123
+ .error-icon { font-size: 32px; }
124
+ .error-state p { color: var(--red); font-size: 14px; }
125
+ .empty-msg { font-size: 13px; color: var(--text-muted); }
126
+
127
+ .docs-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
128
+ @media (max-width: 768px) { .docs-layout { grid-template-columns: 1fr; } }
129
+
130
+ .docs-tree, .docs-recent {
131
+ background: var(--card-bg); border: 1px solid var(--border-light);
132
+ border-radius: var(--radius); padding: 20px;
133
+ }
134
+ h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
135
+
136
+ .tree-list { list-style: none; padding: 0; margin: 0; }
137
+ .tree-item { margin-bottom: 2px; }
138
+ .tree-row {
139
+ display: flex; align-items: center; gap: 8px; padding: 8px 10px;
140
+ border-radius: 6px; cursor: pointer; font-size: 13px;
141
+ }
142
+ .tree-row:hover { background: var(--bg); }
143
+ .tree-icon { font-size: 8px; color: var(--text-muted); width: 12px; text-align: center; flex-shrink: 0; }
144
+ .tree-leaf { font-size: 6px; }
145
+ .tree-title { flex: 1; font-weight: 500; }
146
+ .tree-meta { font-size: 11px; color: var(--text-muted); }
147
+ .tree-children { list-style: none; padding-left: 20px; margin: 0; }
148
+ .tree-child .tree-row { padding: 6px 10px; }
149
+
150
+ .edit-list { list-style: none; padding: 0; margin: 0; }
151
+ .edit-item {
152
+ padding: 10px 0; border-bottom: 1px solid var(--border-light);
153
+ }
154
+ .edit-item:last-child { border-bottom: none; }
155
+ .edit-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
156
+ .edit-meta { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); }
157
+ </style>
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet, apiPost, apiPatch } from '@/api/client'
5
+ import { useAuth } from '@/composables/useAuth'
6
+
7
+ const router = useRouter()
8
+ const { authUser: currentUser } = useAuth()
9
+
10
+ interface Notification {
11
+ id: number
12
+ user_name: string
13
+ type: string
14
+ title: string
15
+ body: string | null
16
+ source_type: string
17
+ source_id: string
18
+ page_id: string
19
+ actor: string
20
+ is_read: number
21
+ created_at: string
22
+ }
23
+
24
+ const notifications = ref<Notification[]>([])
25
+ const loading = ref(true)
26
+
27
+ const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
28
+
29
+ async function loadNotifications() {
30
+ loading.value = true
31
+ const user = currentUser.value
32
+ if (!user) { loading.value = false; return }
33
+ const { data } = await apiGet(`/api/v2/notifications?user=${encodeURIComponent(user)}`)
34
+ if (data?.notifications) notifications.value = data.notifications as Notification[]
35
+ loading.value = false
36
+ }
37
+
38
+ async function markRead(n: Notification) {
39
+ if (!n.is_read) {
40
+ await apiPatch(`/api/v2/notifications/${n.id}/read`, {})
41
+ n.is_read = 1
42
+ }
43
+ navigate(n)
44
+ }
45
+
46
+ async function markAllRead() {
47
+ const user = currentUser.value
48
+ if (!user) return
49
+ await apiPost('/api/v2/notifications/mark-all-read', { user })
50
+ notifications.value.forEach(n => { n.is_read = 1 })
51
+ }
52
+
53
+ function navigate(n: Notification) {
54
+ if (n.source_type === 'story') {
55
+ router.push(`/board?story=${n.source_id}`)
56
+ } else if (n.source_type === 'nudge') {
57
+ router.push('/')
58
+ } else if (n.page_id) {
59
+ router.push(`/${n.page_id}`)
60
+ }
61
+ }
62
+
63
+ function typeIcon(type: string) {
64
+ const map: Record<string, string> = {
65
+ mention: '@', assign: '📋', review: '👀',
66
+ nudge: '🔔', memo: '💬', comment: '💬',
67
+ }
68
+ return map[type] || '📬'
69
+ }
70
+
71
+ function typeColor(type: string) {
72
+ const map: Record<string, string> = {
73
+ mention: '#3b82f6', assign: '#f59e0b', review: '#8b5cf6',
74
+ nudge: '#ef4444', memo: '#10b981',
75
+ }
76
+ return map[type] || '#6b7280'
77
+ }
78
+
79
+ function formatDate(d: string) {
80
+ const date = new Date(d.endsWith('Z') ? d : d + 'Z')
81
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
82
+ }
83
+
84
+ onMounted(loadNotifications)
85
+ </script>
86
+
87
+ <template>
88
+ <div class="inbox-page">
89
+ <div class="inbox-header">
90
+ <h1>Inbox</h1>
91
+ <div class="inbox-actions">
92
+ <span v-if="unreadCount" class="unread-badge">{{ unreadCount }} unread</span>
93
+ <button v-if="unreadCount" class="btn btn--primary btn--sm" @click="markAllRead">Mark All Read</button>
94
+ </div>
95
+ </div>
96
+
97
+ <div v-if="loading" class="loading">Loading...</div>
98
+ <div v-else-if="!notifications.length" class="empty">No notifications.</div>
99
+
100
+ <div v-else class="notification-list">
101
+ <div
102
+ v-for="n in notifications"
103
+ :key="n.id"
104
+ class="notification-item"
105
+ :class="{ unread: !n.is_read }"
106
+ @click="markRead(n)"
107
+ >
108
+ <span class="notif-icon" :style="{ background: typeColor(n.type) }">{{ typeIcon(n.type) }}</span>
109
+ <div class="notif-content">
110
+ <div class="notif-title">{{ n.title }}</div>
111
+ <div v-if="n.body" class="notif-body">{{ n.body.slice(0, 100) }}{{ n.body.length > 100 ? '...' : '' }}</div>
112
+ <div class="notif-meta">
113
+ <span class="notif-actor">{{ n.actor }}</span>
114
+ <span class="notif-date">{{ formatDate(n.created_at) }}</span>
115
+ </div>
116
+ </div>
117
+ <span v-if="!n.is_read" class="notif-dot" />
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .inbox-page { max-width: 700px; margin: 0 auto; padding: 24px 16px; }
125
+ .inbox-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
126
+ .inbox-header h1 { font-size: 20px; font-weight: 700; }
127
+ .inbox-actions { display: flex; align-items: center; gap: 8px; }
128
+ .unread-badge { background: #ef4444; color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
129
+
130
+ .notification-list { display: flex; flex-direction: column; gap: 4px; }
131
+ .notification-item {
132
+ display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
133
+ background: var(--card-bg, #fff); border: 1px solid var(--border-light, #e2e8f0);
134
+ border-radius: 12px; cursor: pointer; transition: transform 0.1s;
135
+ }
136
+ .notification-item:hover { transform: translateY(-1px); }
137
+ .notification-item.unread { background: rgba(59,130,246,0.08); border-left: 3px solid #3b82f6; }
138
+
139
+ .notif-icon {
140
+ width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
141
+ color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0;
142
+ }
143
+ .notif-content { flex: 1; min-width: 0; }
144
+ .notif-title { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
145
+ .notif-body { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
146
+ .notif-meta { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); }
147
+ .notif-actor { font-weight: 500; }
148
+ .notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6; flex-shrink: 0; margin-top: 6px; }
149
+
150
+ .btn { padding: 8px 16px; border: 1px solid var(--border-light, #e2e8f0); border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; background: var(--card-bg, #fff); color: var(--text-secondary); transition: all 0.15s; }
151
+ .btn--primary { background: #1e293b; color: #fff; border-color: #1e293b; }
152
+ .btn--primary:hover { background: #334155; }
153
+ .btn--sm { padding: 4px 10px; font-size: 11px; }
154
+
155
+ .loading, .empty { text-align: center; color: var(--text-muted); padding: 40px; }
156
+ </style>
@@ -0,0 +1,294 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { apiGet, apiPost, apiPatch, isStaticMode } from '@/api/client'
4
+ import MemberSelect from '@/components/MemberSelect.vue'
5
+
6
+ interface Meeting { id: number; title: string; date: string; participants: string | null; created_by: string }
7
+
8
+ const meetings = ref<Meeting[]>([])
9
+ const meetingsLoading = ref(true)
10
+ const showCreate = ref(false)
11
+ const form = ref({ title: '', date: new Date().toISOString().split('T')[0], rawTranscript: '' })
12
+ const selectedParticipants = ref<string[]>([])
13
+ const selectedMeeting = ref<Record<string, unknown> | null>(null)
14
+
15
+ const structurizing = ref(false)
16
+ const uploading = ref(false)
17
+
18
+ const editSummary = ref('')
19
+ const editAgenda = ref('')
20
+ const editDecisions = ref('')
21
+ const editActionItems = ref('')
22
+
23
+ async function loadMeetings() {
24
+ if (isStaticMode()) { meetingsLoading.value = false; return }
25
+ meetingsLoading.value = true
26
+ const { data } = await apiGet<{ meetings: Meeting[] }>('/api/v2/meetings')
27
+ if (data?.meetings) meetings.value = data.meetings
28
+ meetingsLoading.value = false
29
+ }
30
+
31
+ async function createMeeting() {
32
+ await apiPost('/api/v2/meetings', {
33
+ ...form.value,
34
+ participants: selectedParticipants.value.join(', ') || null,
35
+ })
36
+ form.value = { title: '', date: new Date().toISOString().split('T')[0], rawTranscript: '' }
37
+ selectedParticipants.value = []
38
+ showCreate.value = false
39
+ await loadMeetings()
40
+ }
41
+
42
+ async function viewMeeting(id: number) {
43
+ const { data } = await apiGet<{ meeting: Record<string, unknown> }>(`/api/v2/meetings/${id}`)
44
+ if (data?.meeting) {
45
+ selectedMeeting.value = data.meeting
46
+ editSummary.value = (data.meeting.summary as string) ?? ''
47
+ editAgenda.value = (data.meeting.agenda as string) ?? ''
48
+ editDecisions.value = (data.meeting.decisions as string) ?? ''
49
+ editActionItems.value = (data.meeting.action_items as string) ?? ''
50
+ }
51
+ }
52
+
53
+ async function saveMeetingEdits() {
54
+ if (!selectedMeeting.value) return
55
+ await apiPatch(`/api/v2/meetings/${selectedMeeting.value.id}`, {
56
+ summary: editSummary.value || null,
57
+ agenda: editAgenda.value || null,
58
+ decisions: editDecisions.value || null,
59
+ actionItems: editActionItems.value || null,
60
+ })
61
+ await viewMeeting(selectedMeeting.value.id as number)
62
+ }
63
+
64
+ async function uploadAudio(e: Event, meetingId: number) {
65
+ const input = e.target as HTMLInputElement
66
+ const file = input.files?.[0]
67
+ if (!file) return
68
+ if (file.size > 25 * 1024 * 1024) { alert('File size exceeds 25MB limit'); return }
69
+
70
+ uploading.value = true
71
+ const formData = new FormData()
72
+ formData.append('file', file)
73
+
74
+ const url = import.meta.env.VITE_API_URL as string
75
+ const token = localStorage.getItem('spec-auth-token') || ''
76
+ const res = await fetch(`${url}/api/v2/meetings/${meetingId}/transcribe`, {
77
+ method: 'POST',
78
+ headers: { 'Authorization': `Bearer ${token}` },
79
+ body: formData,
80
+ })
81
+ const data = await res.json()
82
+ uploading.value = false
83
+ input.value = ''
84
+
85
+ if (data.error) { alert(data.error); return }
86
+ alert('Transcription complete')
87
+ await viewMeeting(meetingId)
88
+ }
89
+
90
+ async function structurize(id: number) {
91
+ if (!selectedMeeting.value?.raw_transcript) { alert('No transcript available'); return }
92
+
93
+ const { data: settingsData } = await apiGet<{ settings: Record<string, string> }>('/api/v2/admin/settings')
94
+ const settings = settingsData?.settings ?? {}
95
+ const apiKey = settings.llm_api_key
96
+ if (!apiKey) { alert('Please set an API key in /admin settings'); return }
97
+
98
+ const provider = settings.llm_provider ?? (apiKey.startsWith('sk-ant') ? 'anthropic' : apiKey.startsWith('AI') ? 'gemini' : 'openai')
99
+ const model = settings.llm_model ?? (provider === 'openai' ? 'gpt-4o-mini' : provider === 'gemini' ? 'gemini-2.0-flash' : 'claude-sonnet-4-20250514')
100
+ const transcript = selectedMeeting.value.raw_transcript as string
101
+
102
+ const systemPrompt = `You are an expert at structuring meeting transcripts.
103
+ Analyze the transcript below and return a JSON object:
104
+ {
105
+ "summary": "One-line summary",
106
+ "agenda": "Agenda items (newline-separated)",
107
+ "decisions": "Decisions made (newline-separated)",
108
+ "action_items": "Action items (newline-separated, include assignee on each line)"
109
+ }
110
+ Return only JSON.`
111
+
112
+ structurizing.value = true
113
+ try {
114
+ let result: { summary?: string; agenda?: string; decisions?: string; action_items?: string }
115
+
116
+ if (provider === 'openai') {
117
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
120
+ body: JSON.stringify({
121
+ model,
122
+ messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: transcript }],
123
+ response_format: { type: 'json_object' },
124
+ }),
125
+ })
126
+ const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> }
127
+ result = JSON.parse(data.choices?.[0]?.message?.content ?? '{}')
128
+ } else if (provider === 'gemini') {
129
+ const geminiModel = model || 'gemini-2.0-flash'
130
+ const res = await fetch(
131
+ `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`,
132
+ {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({
136
+ contents: [{ parts: [{ text: `${systemPrompt}\n\n${transcript}` }] }],
137
+ generationConfig: { responseMimeType: 'application/json' },
138
+ }),
139
+ },
140
+ )
141
+ const data = await res.json() as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }
142
+ const geminiText = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}'
143
+ const gMatch = geminiText.match(/\{[\s\S]*\}/)
144
+ result = JSON.parse(gMatch?.[0] ?? '{}')
145
+ } else {
146
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
149
+ body: JSON.stringify({
150
+ model, max_tokens: 4096, system: systemPrompt,
151
+ messages: [{ role: 'user', content: transcript }],
152
+ }),
153
+ })
154
+ const data = await res.json() as { content?: Array<{ text?: string }> }
155
+ const text = data.content?.[0]?.text ?? '{}'
156
+ const jsonMatch = text.match(/\{[\s\S]*\}/)
157
+ result = JSON.parse(jsonMatch?.[0] ?? '{}')
158
+ }
159
+
160
+ editSummary.value = result.summary ?? ''
161
+ editAgenda.value = result.agenda ?? ''
162
+ editDecisions.value = result.decisions ?? ''
163
+ editActionItems.value = result.action_items ?? ''
164
+ await saveMeetingEdits()
165
+ await viewMeeting(id)
166
+ } catch (e) {
167
+ alert(`AI structuring failed: ${String(e)}`)
168
+ } finally { structurizing.value = false }
169
+ }
170
+
171
+ async function createTasks(id: number) {
172
+ const { data } = await apiPost(`/api/v2/meetings/${id}/create-tasks`, {})
173
+ if (data) alert(`${(data as any).created} tasks created`)
174
+ }
175
+
176
+ onMounted(loadMeetings)
177
+ </script>
178
+
179
+ <template>
180
+ <div class="meetings-page">
181
+ <div class="meetings-header">
182
+ <h1>Meeting Notes</h1>
183
+ <button class="btn btn--primary" @click="showCreate = !showCreate">+ New Meeting</button>
184
+ </div>
185
+
186
+ <!-- Create form -->
187
+ <div v-if="showCreate" class="create-form glass-card">
188
+ <input v-model="form.title" class="form-input" placeholder="Meeting title" />
189
+ <input v-model="form.date" type="date" class="form-input" />
190
+ <div class="participants-select">
191
+ <span class="participants-label">Participants:</span>
192
+ <MemberSelect v-model="selectedParticipants" />
193
+ </div>
194
+ <textarea v-model="form.rawTranscript" class="form-textarea" placeholder="Paste transcript here..." rows="8"></textarea>
195
+ <button class="btn btn--primary" @click="createMeeting">Save</button>
196
+ </div>
197
+
198
+ <!-- List -->
199
+ <div class="meetings-list">
200
+ <div v-for="m in meetings" :key="m.id" class="meeting-card glass-card" @click="viewMeeting(m.id)">
201
+ <div class="meeting-title">{{ m.title }}</div>
202
+ <div class="meeting-meta">
203
+ <span>{{ m.date }}</span>
204
+ <span v-if="m.participants">{{ m.participants }}</span>
205
+ <span>{{ m.created_by }}</span>
206
+ </div>
207
+ </div>
208
+ <div v-if="meetingsLoading" class="empty">Loading...</div>
209
+ <div v-else-if="!meetings.length" class="empty">No meeting notes yet. Create one to get started.</div>
210
+ </div>
211
+
212
+ <!-- Detail -->
213
+ <div v-if="selectedMeeting" class="meeting-detail glass-card">
214
+ <div class="detail-header">
215
+ <h2>{{ selectedMeeting.title }}</h2>
216
+ <button class="btn btn--sm" @click="selectedMeeting = null">Close</button>
217
+ </div>
218
+ <div class="detail-meta">{{ selectedMeeting.date }} | {{ selectedMeeting.participants }}</div>
219
+
220
+ <!-- Structured results (editable) -->
221
+ <div class="detail-section">
222
+ <h3>Summary</h3>
223
+ <textarea v-model="editSummary" class="edit-textarea" rows="2" placeholder="Meeting summary"></textarea>
224
+ </div>
225
+ <div class="detail-section">
226
+ <h3>Agenda</h3>
227
+ <textarea v-model="editAgenda" class="edit-textarea" rows="3" placeholder="Agenda items (one per line)"></textarea>
228
+ </div>
229
+ <div class="detail-section">
230
+ <h3>Decisions</h3>
231
+ <textarea v-model="editDecisions" class="edit-textarea" rows="3" placeholder="Decisions (one per line)"></textarea>
232
+ </div>
233
+ <div class="detail-section">
234
+ <h3>Action Items</h3>
235
+ <textarea v-model="editActionItems" class="edit-textarea" rows="3" placeholder="Action items (one per line, include assignee)"></textarea>
236
+ <button v-if="editActionItems" class="btn btn--sm btn--primary" @click="createTasks(selectedMeeting.id as number)">Auto-create Tasks</button>
237
+ </div>
238
+
239
+ <div class="detail-actions">
240
+ <button class="btn btn--primary" @click="saveMeetingEdits">Save</button>
241
+ <label class="btn btn--sm upload-btn">
242
+ Upload Audio
243
+ <input type="file" accept=".mp3,.wav,.m4a,.webm,.ogg" hidden @change="uploadAudio($event, selectedMeeting.id as number)" />
244
+ </label>
245
+ <span v-if="uploading" class="upload-status">Transcribing...</span>
246
+ <button v-if="selectedMeeting.raw_transcript" class="btn btn--sm" :disabled="structurizing" @click="structurize(selectedMeeting.id as number)">
247
+ {{ structurizing ? 'AI Structuring...' : 'AI Structure' }}
248
+ </button>
249
+ </div>
250
+
251
+ <div v-if="selectedMeeting.raw_transcript" class="detail-section">
252
+ <h3>Transcript</h3>
253
+ <pre class="transcript">{{ selectedMeeting.raw_transcript }}</pre>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </template>
258
+
259
+ <style scoped>
260
+ .meetings-page { max-width: 800px; margin: 0 auto; padding: 24px; min-height: 100vh; }
261
+ .meetings-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
262
+ .meetings-header h1 { font-size: 22px; font-weight: 700; }
263
+ .create-form { padding: 20px; display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
264
+ .form-input { padding: 8px 12px; border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; font-size: 14px; }
265
+ .form-textarea { padding: 8px 12px; border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; font-size: 13px; font-family: monospace; resize: vertical; }
266
+ .meetings-list { display: flex; flex-direction: column; gap: 8px; }
267
+ .meeting-card { padding: 16px; cursor: pointer; }
268
+ .meeting-card:hover { transform: translateY(-1px); }
269
+ .meeting-title { font-size: 15px; font-weight: 600; }
270
+ .meeting-meta { font-size: 12px; color: var(--text-secondary); display: flex; gap: 12px; margin-top: 4px; }
271
+ .meeting-detail { padding: 24px; margin-top: 20px; }
272
+ .detail-header { display: flex; justify-content: space-between; align-items: center; }
273
+ .detail-header h2 { font-size: 18px; }
274
+ .detail-meta { font-size: 13px; color: var(--text-secondary); margin: 8px 0 16px; }
275
+ .detail-section { margin-bottom: 16px; }
276
+ .detail-section h3 { font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
277
+ .detail-section pre { font-size: 13px; white-space: pre-wrap; line-height: 1.6; }
278
+ .transcript { max-height: 300px; overflow-y: auto; background: rgba(0,0,0,0.03); padding: 12px; border-radius: 8px; }
279
+ .empty { color: var(--text-muted); padding: 20px; text-align: center; }
280
+ .edit-textarea { width: 100%; padding: 8px 12px; border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; font-size: 13px; resize: vertical; font-family: inherit; }
281
+ .detail-actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
282
+ .participants-select { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
283
+ .participants-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; flex-shrink: 0; }
284
+ .btn { padding: 8px 16px; border-radius: 8px; border: none; font-size: 14px; font-weight: 500; cursor: pointer; }
285
+ .btn--primary { background: var(--primary); color: #fff; }
286
+ .btn--sm { padding: 4px 10px; font-size: 12px; }
287
+ .glass-card {
288
+ background: rgba(255,255,255,0.25); backdrop-filter: blur(40px) saturate(1.8);
289
+ border: 1px solid rgba(255,255,255,0.45); border-radius: 16px;
290
+ box-shadow: 0 2px 12px rgba(0,0,0,0.03), inset 0 1px 0 rgba(255,255,255,0.5);
291
+ }
292
+ .upload-btn { cursor: pointer; background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
293
+ .upload-status { font-size: 12px; color: #f59e0b; }
294
+ </style>