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,444 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, onMounted, computed, nextTick, onUnmounted, watch } from 'vue'
4
+ import { useRoute } from 'vue-router'
5
+ import { renderMarkdown } from '@/utils/markdown'
6
+ import DocsSidebar from '@/components/DocsSidebar.vue'
7
+ import DocEditor from '@/components/DocEditor.vue'
8
+ import DocComments from '@/components/DocComments.vue'
9
+
10
+ const route = useRoute()
11
+ const docId = computed(() => route.params.docId as string)
12
+ const content = ref('')
13
+ const title = ref('')
14
+ const author = ref('')
15
+ const updatedAt = ref('')
16
+ const contentFormat = ref('markdown')
17
+ const loading = ref(true)
18
+ const editing = ref(false) // skip polling while editing
19
+ const childDocs = ref<{ id: string; title: string; icon: string | null }[]>([])
20
+ const docIcon = ref('📄')
21
+ const sourceMemos = ref<any[]>([])
22
+ const showEmojiPicker = ref(false)
23
+ const DOC_EMOJIS = ['📄', '📋', '🏃', '📌', '🔖', '📊', '🎯', '💡', '🔧', '📦', '🚀', '⚡', '🎨', '📐', '🔒', '🌐', '📁', '📅', '💼', '🛠️']
24
+
25
+ async function changeDocIcon(icon: string) {
26
+ docIcon.value = icon
27
+ showEmojiPicker.value = false
28
+ const { apiPatch: ap } = await import('@/composables/useTurso')
29
+ await ap(`/api/v2/docs/${docId.value}`, { icon })
30
+ }
31
+ const editContent = ref('')
32
+ const docTags = ref<string[]>([])
33
+ const newTag = ref('')
34
+
35
+ function addTag() {
36
+ const tag = newTag.value.trim()
37
+ if (!tag || docTags.value.includes(tag)) return
38
+ docTags.value.push(tag)
39
+ newTag.value = ''
40
+ saveTags()
41
+ }
42
+
43
+ function removeTag(tag: string) {
44
+ docTags.value = docTags.value.filter(t => t !== tag)
45
+ saveTags()
46
+ }
47
+
48
+ async function onTitleBlur(e: Event) {
49
+ const newTitle = (e.target as HTMLElement).textContent?.trim() || ''
50
+ if (newTitle && newTitle !== title.value) {
51
+ title.value = newTitle
52
+ const { apiPatch: ap } = await import('@/composables/useTurso')
53
+ await ap(`/api/v2/docs/${docId.value}`, { title: newTitle })
54
+ }
55
+ }
56
+
57
+ async function saveTags() {
58
+ const { apiPatch: ap } = await import('@/composables/useTurso')
59
+ await ap(`/api/v2/docs/${docId.value}`, { tags: docTags.value })
60
+ }
61
+ const saveStatus = ref<'saved' | 'saving' | 'changed'>('saved')
62
+ let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
63
+ const showHistory = ref(false)
64
+ const revisions = ref<any[]>([])
65
+ const previewRevision = ref<any>(null)
66
+
67
+ async function saveDoc() {
68
+ saveStatus.value = 'saving'
69
+ const { apiPut } = await import('@/composables/useTurso')
70
+ await apiPut(`/api/v2/docs/${docId.value}`, { title: title.value, content: editContent.value, contentFormat: 'html' })
71
+ saveStatus.value = 'saved'
72
+ // Re-sync baseline after own save
73
+ const { apiGet: ag2 } = await import('@/composables/useTurso')
74
+ const { data: freshDoc } = await ag2(`/api/v2/docs/${docId.value}`)
75
+ remoteUpdatedAt.value = (freshDoc as any)?.doc?.updated_at || ''
76
+ hasRemoteUpdate.value = false
77
+ }
78
+
79
+ function onEditorChange(val: string) {
80
+ editContent.value = val
81
+ saveStatus.value = 'changed'
82
+ if (autoSaveTimer) clearTimeout(autoSaveTimer)
83
+ autoSaveTimer = setTimeout(() => saveDoc(), 3000)
84
+ }
85
+
86
+
87
+
88
+ function onBeforeUnload(e: BeforeUnloadEvent) {
89
+ if (saveStatus.value === 'changed') {
90
+ e.preventDefault()
91
+ e.returnValue = ''
92
+ }
93
+ }
94
+
95
+ async function loadRevisions() {
96
+ const { apiGet: ag } = await import('@/composables/useTurso')
97
+ const { data } = await ag(`/api/v2/docs/${docId.value}/revisions`)
98
+ revisions.value = (data as any)?.revisions || []
99
+ showHistory.value = true
100
+ }
101
+
102
+ async function previewRev(revId: number) {
103
+ const { apiGet: ag } = await import('@/composables/useTurso')
104
+ const { data } = await ag(`/api/v2/docs/${docId.value}/revisions/${revId}`)
105
+ previewRevision.value = (data as any)?.revision || null
106
+ }
107
+
108
+ async function restoreRev(revId: number) {
109
+ if (!confirm('Restore this version?')) return
110
+ const { apiPost: ap } = await import('@/composables/useTurso')
111
+ await ap(`/api/v2/docs/${docId.value}/revisions/restore/${revId}`, {})
112
+ location.reload()
113
+ }
114
+ const toc = ref<Array<{ id: string; text: string; level: number }>>([])
115
+ const activeHeading = ref('')
116
+
117
+ function formatDate(d: string) {
118
+ if (!d) return ''
119
+ const date = new Date(d.endsWith('Z') ? d : d + 'Z')
120
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
121
+ }
122
+
123
+ async function loadDoc() {
124
+ loading.value = true
125
+ try {
126
+ const { apiGet } = await import('@/composables/useTurso')
127
+ const { data } = await apiGet<{ doc: { title: string; content: string; created_by?: string; updated_at?: string } }>(`/api/v2/docs/${docId.value}`)
128
+ if (data?.doc) {
129
+ content.value = data.doc.content
130
+ title.value = data.doc.title
131
+ author.value = data.doc.created_by || ''
132
+ updatedAt.value = data.doc.updated_at || ''
133
+ remoteUpdatedAt.value = data.doc.updated_at || '' // sync polling baseline
134
+ contentFormat.value = (data.doc as any).content_format || 'markdown'
135
+ try { docTags.value = JSON.parse((data.doc as any).tags || '[]') } catch { docTags.value = [] }
136
+ docIcon.value = (data.doc as any).icon || '📄'
137
+ // Source memos
138
+ const { data: srcData } = await apiGet(`/api/v2/docs/${docId.value}/source-memos`)
139
+ sourceMemos.value = (srcData as any)?.memos || []
140
+ // Markdown to HTML conversion
141
+ if (contentFormat.value === 'markdown' || !contentFormat.value || contentFormat.value === 'null') {
142
+ // One-time markdown to HTML conversion + update DB
143
+ const html = renderMarkdown(content.value)
144
+ editContent.value = html
145
+ content.value = html
146
+ contentFormat.value = 'html'
147
+ // DB migration (one-time)
148
+ const { apiPut: ap2 } = await import('@/composables/useTurso')
149
+ await ap2(`/api/v2/docs/${docId.value}`, { title: title.value, content: html, contentFormat: 'html' })
150
+ } else {
151
+ editContent.value = content.value
152
+ }
153
+ // Fetch child documents
154
+ const { data: treeData } = await apiGet<{ tree: any[] }>('/api/v2/docs/tree')
155
+ if (treeData?.tree) {
156
+ function findNode(nodes: any[], id: string): any {
157
+ for (const n of nodes) {
158
+ if (n.id === id) return n
159
+ const found = findNode(n.children || [], id)
160
+ if (found) return found
161
+ }
162
+ return null
163
+ }
164
+ const node = findNode(treeData.tree, docId.value)
165
+ childDocs.value = (node?.children || []).map((c: any) => ({ id: c.id, title: c.title, icon: c.icon }))
166
+ }
167
+ } else {
168
+ // Document not found — auto-create new document
169
+ const { apiPut } = await import('@/composables/useTurso')
170
+ await apiPut(`/api/v2/docs/${docId.value}`, { title: 'New Document', content: '' })
171
+ title.value = 'New Document'
172
+ editContent.value = ''
173
+ }
174
+ } catch (_) {
175
+ content.value = '# Failed to load document'
176
+ }
177
+ loading.value = false
178
+ await nextTick()
179
+ buildToc()
180
+ addCopyButtons()
181
+ window.addEventListener('scroll', onScroll)
182
+ }
183
+
184
+ // Real-time document refresh (compare updated_at + visibility/focus polling)
185
+ let docPollTimer: ReturnType<typeof setInterval> | null = null
186
+ const remoteUpdatedAt = ref('')
187
+ const hasRemoteUpdate = ref(false)
188
+
189
+ async function checkForUpdates() {
190
+ if (editing.value) return
191
+ const { apiGet } = await import('@/composables/useTurso')
192
+ const { data } = await apiGet(`/api/v2/docs/${docId.value}`)
193
+ const remote = (data as any)?.doc?.updated_at || ''
194
+ if (remote && remoteUpdatedAt.value && remote !== remoteUpdatedAt.value) {
195
+ hasRemoteUpdate.value = true
196
+ }
197
+ if (!remoteUpdatedAt.value) remoteUpdatedAt.value = remote
198
+ }
199
+
200
+ function onVisibilityChange() {
201
+ if (!document.hidden) checkForUpdates()
202
+ }
203
+
204
+ function applyRemoteUpdate() {
205
+ hasRemoteUpdate.value = false
206
+ loadDoc()
207
+ }
208
+
209
+ onMounted(() => {
210
+ loadDoc()
211
+ window.addEventListener('beforeunload', onBeforeUnload)
212
+ document.addEventListener('visibilitychange', onVisibilityChange)
213
+ docPollTimer = setInterval(checkForUpdates, 30000)
214
+ })
215
+ watch(docId, () => { remoteUpdatedAt.value = ''; hasRemoteUpdate.value = false; loadDoc() })
216
+ onUnmounted(() => {
217
+ window.removeEventListener('scroll', onScroll)
218
+ window.removeEventListener('beforeunload', onBeforeUnload)
219
+ document.removeEventListener('visibilitychange', onVisibilityChange)
220
+ if (docPollTimer) clearInterval(docPollTimer)
221
+ })
222
+
223
+ function buildToc() {
224
+ const el = document.querySelector('.docs-body')
225
+ if (!el) return
226
+ const headings = el.querySelectorAll('h1, h2, h3')
227
+ toc.value = Array.from(headings).map((h, i) => {
228
+ const id = `h-${i}`
229
+ h.id = id
230
+ return { id, text: h.textContent || '', level: parseInt(h.tagName[1]) }
231
+ })
232
+ }
233
+
234
+ function onScroll() {
235
+ const headings = toc.value.map(t => document.getElementById(t.id)).filter(Boolean)
236
+ for (let i = headings.length - 1; i >= 0; i--) {
237
+ if (headings[i]!.getBoundingClientRect().top <= 80) {
238
+ activeHeading.value = toc.value[i].id
239
+ return
240
+ }
241
+ }
242
+ if (toc.value.length) activeHeading.value = toc.value[0].id
243
+ }
244
+
245
+ function scrollTo(id: string) {
246
+ document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
247
+ }
248
+
249
+ function addCopyButtons() {
250
+ const el = document.querySelector('.docs-body')
251
+ if (!el) return
252
+ el.querySelectorAll('pre').forEach(pre => {
253
+ if (pre.querySelector('.code-copy-btn')) return
254
+ const btn = document.createElement('button')
255
+ btn.className = 'code-copy-btn'
256
+ btn.textContent = 'Copy'
257
+ btn.addEventListener('click', () => {
258
+ const code = pre.querySelector('code')
259
+ navigator.clipboard.writeText(code?.textContent || pre.textContent || '')
260
+ btn.textContent = 'Copied!'
261
+ setTimeout(() => { btn.textContent = 'Copy' }, 1500)
262
+ })
263
+ pre.style.position = 'relative'
264
+ pre.appendChild(btn)
265
+ })
266
+ }
267
+
268
+ const renderedHtml = computed(() => renderMarkdown(content.value))
269
+ </script>
270
+
271
+ <template>
272
+ <div class="docs-page-wrap">
273
+ <DocsSidebar :active-doc-id="docId" />
274
+ <div class="docs-layout">
275
+ <!-- Table of Contents -->
276
+ <aside v-if="toc.length > 2" class="docs-toc">
277
+ <div class="toc-title">Contents</div>
278
+ <nav>
279
+ <a
280
+ v-for="item in toc"
281
+ :key="item.id"
282
+ :class="['toc-link', `toc-h${item.level}`, { active: activeHeading === item.id }]"
283
+ @click.prevent="scrollTo(item.id)"
284
+ >{{ item.text }}</a>
285
+ </nav>
286
+ </aside>
287
+
288
+ <!-- Main Content -->
289
+ <main class="docs-main">
290
+ <!-- Remote update notification -->
291
+ <div v-if="hasRemoteUpdate" class="remote-update-banner">
292
+ ⚡ Another user has modified this document. <button @click="applyRemoteUpdate">Refresh</button>
293
+ </div>
294
+ <div v-if="loading" class="loading">Loading...</div>
295
+
296
+ <template v-else>
297
+ <div class="docs-meta">
298
+ <div class="docs-title-row">
299
+ <span class="doc-icon-btn" @click="showEmojiPicker = !showEmojiPicker">{{ docIcon }}</span>
300
+ <div v-if="showEmojiPicker" class="doc-emoji-picker" @click.stop>
301
+ <span v-for="e in DOC_EMOJIS" :key="e" class="emoji-opt" @click="changeDocIcon(e)">{{ e }}</span>
302
+ </div>
303
+ <h1 v-if="title" class="docs-title" contenteditable @blur="onTitleBlur" @keydown.enter.prevent="($event.target as HTMLElement).blur()">{{ title }}</h1>
304
+ </div>
305
+ <div class="meta-info">
306
+ <span v-if="author" class="meta-author">{{ author }}</span>
307
+ <span v-if="updatedAt" class="meta-date">{{ formatDate(updatedAt) }}</span>
308
+ </div>
309
+ </div>
310
+
311
+ <div class="docs-actions">
312
+ <span class="save-status">{{ saveStatus === 'saving' ? 'Saving...' : saveStatus === 'changed' ? 'Unsaved changes' : 'Saved <Icon name="check" :size="14" />' }}</span>
313
+ <button class="btn btn--sm" @click="loadRevisions">History</button>
314
+ </div>
315
+
316
+ <!-- History panel -->
317
+ <div v-if="showHistory" class="history-panel">
318
+ <div class="history-header">
319
+ <span>Revision History</span>
320
+ <button class="btn btn--xs" @click="showHistory = false">Close</button>
321
+ </div>
322
+ <div v-for="rev in revisions" :key="rev.id" class="history-item" @click="previewRev(rev.id)">
323
+ <span>{{ rev.edited_by }} — {{ rev.created_at }}</span>
324
+ <button class="btn btn--xs" @click.stop="restoreRev(rev.id)">Restore</button>
325
+ </div>
326
+ <div v-if="previewRevision" class="history-preview" v-html="previewRevision.content"></div>
327
+ </div>
328
+
329
+ <!-- Tags -->
330
+ <div class="tags-editor">
331
+ <span class="tags-label">Tags:</span>
332
+ <span v-for="tag in docTags" :key="tag" class="tag-chip">{{ tag }} <button class="tag-remove" @click="removeTag(tag)">✕</button></span>
333
+ <input v-model="newTag" class="tag-input" placeholder="Add tag..." @keyup.enter="addTag" />
334
+ </div>
335
+
336
+ <!-- Child documents -->
337
+ <div v-if="childDocs.length" class="child-docs">
338
+ <div class="child-docs-title">Sub-documents ({{ childDocs.length }})</div>
339
+ <div v-for="c in childDocs" :key="c.id" class="child-doc-item" @click="$router.push(`/docs/${c.id}`)">
340
+ <span>{{ c.icon || '📄' }}</span>
341
+ <span>{{ c.title }}</span>
342
+ </div>
343
+ </div>
344
+
345
+ <!-- Source memos -->
346
+ <div v-if="sourceMemos.length" class="source-memos">
347
+ <span class="source-memos-title">Source Memos</span>
348
+ <div v-for="sm in sourceMemos" :key="sm.memo_id" class="source-memo-item" @click="$router.push(`/memos/${sm.memo_id}`)">
349
+ <Icon name="messageCircle" :size="14" /> #{{ sm.memo_id }} {{ (sm.content || '').split('\n')[0].slice(0, 50) }}
350
+ </div>
351
+ </div>
352
+
353
+ <DocEditor :model-value="editContent" @update:model-value="onEditorChange" @focus="editing = true" @blur="editing = false" :editable="true" />
354
+
355
+ <DocComments :doc-id="docId" :current-user="author" />
356
+ </template>
357
+ </main>
358
+ </div>
359
+ </div>
360
+ </template>
361
+
362
+ <style scoped>
363
+ .docs-page-wrap { display: flex; height: calc(100vh - 60px); background: var(--bg-main, #f5f6f8); }
364
+ .docs-layout { display: flex; max-width: 1100px; margin: 0 auto; padding: 32px 24px; gap: 32px; flex: 1; overflow-y: auto; }
365
+
366
+ /* TOC */
367
+ .docs-toc { width: 200px; flex-shrink: 0; position: sticky; top: 80px; height: fit-content; max-height: calc(100vh - 120px); overflow-y: auto; }
368
+ .toc-title { font-size: 12px; font-weight: 700; text-transform: uppercase; color: #9ca3af; letter-spacing: 0.05em; margin-bottom: 12px; }
369
+ .toc-link { display: block; font-size: 13px; color: #6b7280; padding: 4px 0 4px 12px; border-left: 2px solid transparent; text-decoration: none; cursor: pointer; transition: all 0.15s; }
370
+ .toc-link:hover { color: #111; }
371
+ .toc-link.active { color: #3b82f6; border-left-color: #3b82f6; font-weight: 500; }
372
+ .toc-h2 { padding-left: 12px; }
373
+ .toc-h3 { padding-left: 24px; font-size: 12px; }
374
+
375
+ /* Main content */
376
+ .docs-main { flex: 1; max-width: 720px; min-width: 0; }
377
+ .docs-meta { margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid #e5e7eb; }
378
+ .docs-content-area { max-width: 720px; margin: 0 auto; padding: 40px 24px; background: var(--bg-card, #fff); min-height: 100%; }
379
+ .docs-title-row { display: flex; align-items: center; gap: 8px; position: relative; margin-bottom: 8px; }
380
+ .doc-icon-btn { font-size: 28px; cursor: pointer; padding: 4px; border-radius: 6px; }
381
+ .doc-icon-btn:hover { background: #f3f4f6; }
382
+ .doc-emoji-picker { position: absolute; top: 40px; left: 0; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 6px; display: flex; flex-wrap: wrap; gap: 2px; width: 200px; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
383
+ .emoji-opt { cursor: pointer; font-size: 18px; padding: 3px; border-radius: 4px; }
384
+ .emoji-opt:hover { background: #f3f4f6; }
385
+ .docs-title { font-size: 32px; font-weight: 700; line-height: 1.3; color: #1a1a1a; outline: none; cursor: text; border: none; flex: 1; }
386
+ .docs-title:empty::before { content: 'Untitled'; color: #9ca3af; }
387
+ .docs-meta { font-size: 12px; color: #9ca3af; margin-bottom: 24px; }
388
+ .meta-info { display: flex; gap: 12px; margin-top: 8px; font-size: 14px; color: #9ca3af; }
389
+ .meta-author { font-weight: 500; }
390
+
391
+ /* Content */
392
+ .docs-body { font-size: 16px; line-height: 1.8; color: #374151; }
393
+ .docs-body :deep(h1) { font-size: 32px; font-weight: 700; margin: 48px 0 16px; color: #111; }
394
+ .docs-body :deep(h2) { font-size: 24px; font-weight: 600; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #e5e7eb; color: #111; }
395
+ .docs-body :deep(h3) { font-size: 18px; font-weight: 500; margin: 32px 0 12px; color: #111; }
396
+ .docs-body :deep(p) { margin-bottom: 16px; }
397
+ .docs-body :deep(code) { background: #f1f3f5; padding: 2px 6px; border-radius: 4px; font-size: 14px; font-family: 'Fira Code', 'Consolas', monospace; color: #e83e8c; }
398
+ .docs-body :deep(pre) { position: relative; background: #1e1e2e; color: #cdd6f4; border-radius: 8px; padding: 16px; overflow-x: auto; margin: 24px 0; font-size: 14px; line-height: 1.6; }
399
+ .docs-body :deep(pre code) { background: none; padding: 0; color: inherit; font-size: 14px; }
400
+ .docs-body :deep(.code-copy-btn) { position: absolute; top: 8px; right: 8px; background: rgba(255,255,255,0.1); color: #9ca3af; border: none; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; }
401
+ .docs-body :deep(.code-copy-btn:hover) { background: rgba(255,255,255,0.2); color: #fff; }
402
+ .docs-body :deep(blockquote) { border-left: 4px solid #3b82f6; background: #f0f4ff; padding: 12px 16px; margin: 24px 0; border-radius: 0 8px 8px 0; color: #374151; }
403
+ .docs-body :deep(blockquote p) { margin: 0; }
404
+ .docs-body :deep(hr) { border: none; border-top: 1px solid #e5e7eb; margin: 48px 0; }
405
+ .docs-body :deep(a) { color: #3b82f6; text-decoration: none; }
406
+ .docs-body :deep(a:hover) { text-decoration: underline; }
407
+ .docs-body :deep(ul), .docs-body :deep(ol) { padding-left: 24px; margin: 16px 0; }
408
+ .docs-body :deep(li) { margin: 6px 0; }
409
+ .docs-body :deep(table) { width: 100%; border-collapse: collapse; margin: 24px 0; }
410
+ .docs-body :deep(thead th) { background: #f8f9fa; font-weight: 600; text-align: left; }
411
+ .docs-body :deep(th), .docs-body :deep(td) { padding: 10px 14px; border: 1px solid #e5e7eb; font-size: 14px; }
412
+ .docs-body :deep(tbody tr:nth-child(even)) { background: #fafafa; }
413
+ .docs-body :deep(img) { max-width: 100%; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin: 16px 0; }
414
+ .docs-body :deep(strong) { font-weight: 600; color: #111; }
415
+
416
+ .loading { text-align: center; color: #9ca3af; padding: 60px; }
417
+
418
+ @media (max-width: 768px) {
419
+ .docs-layout { flex-direction: column; padding: 16px; }
420
+ .docs-toc { display: none; }
421
+ .docs-main { max-width: 100%; }
422
+ .docs-title { font-size: 24px; }
423
+ }
424
+ .source-memos { margin: 8px 0; padding: 8px; background: #fef3c7; border-radius: 6px; }
425
+ .source-memos-title { font-size: 12px; font-weight: 600; color: #92400e; display: block; margin-bottom: 4px; }
426
+ .source-memo-item { padding: 4px 0; font-size: 13px; color: #92400e; cursor: pointer; }
427
+ .source-memo-item:hover { text-decoration: underline; }
428
+ .child-docs { margin: 12px 0; padding: 12px; background: #fafafa; border-radius: 8px; border: 1px solid #e5e7eb; }
429
+ .child-docs-title { font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 8px; }
430
+ .child-doc-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; }
431
+ .child-doc-item:hover { background: #eff6ff; }
432
+ .tags-editor, .tags-display { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin: 8px 0; }
433
+ .tags-label { font-size: 12px; font-weight: 600; color: #6b7280; }
434
+ .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #eff6ff; color: #3b82f6; display: flex; align-items: center; gap: 2px; }
435
+ .tag-chip.readonly { cursor: default; }
436
+ .tag-remove { border: none; background: none; font-size: 10px; cursor: pointer; color: #3b82f6; padding: 0; }
437
+ .tag-input { border: 1px solid #d1d5db; border-radius: 6px; padding: 2px 8px; font-size: 11px; width: 100px; }
438
+ .save-status { font-size: 11px; color: #9ca3af; }
439
+ .history-panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin: 12px 0; background: #fafafa; }
440
+ .history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-weight: 600; font-size: 14px; }
441
+ .history-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #f3f4f6; font-size: 12px; cursor: pointer; }
442
+ .history-item:hover { background: #f3f4f6; }
443
+ .history-preview { margin-top: 12px; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; font-size: 13px; max-height: 300px; overflow-y: auto; }
444
+ </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>