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,353 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import type { MemoItem, MemoType, ReplyItem } from '@/composables/useMemo'
4
+ import { MEMO_TYPES } from '@/composables/useMemo'
5
+ import { parseMentions } from '@/utils/parseMentions'
6
+ import { useRouter } from 'vue-router'
7
+ import MentionInput from '@/components/MentionInput.vue'
8
+
9
+ const props = defineProps<{
10
+ memo: MemoItem
11
+ replies: ReplyItem[]
12
+ authUser: string | null
13
+ replyOpenId: number | null
14
+ replyText: string
15
+ replyReviewType: string
16
+ showPageLabel?: boolean
17
+ }>()
18
+
19
+ const router = useRouter()
20
+
21
+ const emit = defineEmits<{
22
+ resolve: [id: number]
23
+ reopen: [id: number]
24
+ delete: [id: number]
25
+ toggleReply: [id: number]
26
+ 'update:replyText': [text: string]
27
+ 'update:replyReviewType': [type: string]
28
+ addReply: [memoId: number]
29
+ deleteReply: [replyId: number]
30
+ convertToTask: [memo: MemoItem]
31
+ convertToInitiative: [memo: MemoItem]
32
+ }>()
33
+
34
+ function getMemoTypeInfo(type: MemoType | undefined) {
35
+ return MEMO_TYPES.find(t => t.value === type) ?? MEMO_TYPES[0]
36
+ }
37
+
38
+ function formatTime(ts: number | string): string {
39
+ const d = typeof ts === 'string' ? new Date(ts) : new Date(ts)
40
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
41
+ const dd = String(d.getDate()).padStart(2, '0')
42
+ const hh = String(d.getHours()).padStart(2, '0')
43
+ const mi = String(d.getMinutes()).padStart(2, '0')
44
+ return `${mm}/${dd} ${hh}:${mi}`
45
+ }
46
+
47
+ function handleMentionClick(e: MouseEvent) {
48
+ const target = e.target as HTMLElement
49
+ if (target.classList.contains('memo-mention')) {
50
+ e.preventDefault()
51
+ const path = target.getAttribute('data-mention-page')
52
+ if (path) router.push(path)
53
+ }
54
+ }
55
+
56
+ const PAGE_LABELS: Record<string, string> = {
57
+ home: 'Home', diagnosis: 'AI Diagnosis', worknote: 'Coaching Notes',
58
+ onboarding: 'Onboarding', pricing: 'Pricing',
59
+ }
60
+
61
+ function getPageLabel(pageId: string): string {
62
+ if (PAGE_LABELS[pageId]) return PAGE_LABELS[pageId]
63
+ if (pageId.startsWith('policy/')) {
64
+ const parts = pageId.split('/')
65
+ return parts.length === 3 ? `Policy ${parts[2]}` : `Policy`
66
+ }
67
+ if (pageId.startsWith('retro/')) return 'Retrospective'
68
+ return pageId
69
+ }
70
+ </script>
71
+
72
+ <template>
73
+ <div
74
+ class="memo-item"
75
+ :class="[`memo-item--${memo.memo_type}`, { 'memo-item--resolved': memo.status === 'resolved' }]"
76
+ >
77
+ <div class="memo-item-type-bar" :style="{ background: getMemoTypeInfo(memo.memo_type).color }"></div>
78
+ <div class="memo-item-content">
79
+ <div class="memo-item-header">
80
+ <span class="memo-item-type-label">{{ getMemoTypeInfo(memo.memo_type).icon }} {{ getMemoTypeInfo(memo.memo_type).label }}</span>
81
+ <span class="memo-item-time">{{ formatTime(memo.ts) }}</span>
82
+ </div>
83
+ <div class="memo-item-route">
84
+ <span class="memo-item-author">{{ memo.author }}</span>
85
+ <span class="memo-item-arrow">→</span>
86
+ <template v-if="memo.assigned_to">
87
+ <span
88
+ v-for="name in memo.assigned_to.split(',').map((s: string) => s.trim()).filter(Boolean)"
89
+ :key="name"
90
+ class="memo-item-recipient-tag"
91
+ >@{{ name }}</span>
92
+ </template>
93
+ <span v-else class="memo-item-assigned memo-item-assigned--all">Everyone</span>
94
+ </div>
95
+ <div v-if="showPageLabel && memo.page_id" class="memo-item-page" @click="router.push('/' + memo.page_id)">
96
+ 📄 {{ getPageLabel(memo.page_id) }}
97
+ </div>
98
+ <div v-if="memo.title" class="memo-item-title">{{ memo.title }}</div>
99
+ <div
100
+ class="memo-item-text"
101
+ :class="{ 'memo-item-text--resolved': memo.status === 'resolved' }"
102
+ v-html="parseMentions(memo.text)"
103
+ @click="handleMentionClick"
104
+ ></div>
105
+
106
+ <!-- Replies -->
107
+ <div v-if="replies.length" class="memo-reply-list">
108
+ <div v-for="r in replies" :key="r.id" class="memo-reply-item" :class="`reply-${(r.review_type || 'comment').replace('_', '-')}`">
109
+ <div class="memo-reply-header">
110
+ <span v-if="r.review_type === 'approve'" class="memo-review-badge badge-approve"><Icon name="check" :size="14" /> Approved</span>
111
+ <span v-else-if="r.review_type === 'request_changes'" class="memo-review-badge badge-changes"><Icon name="refreshCw" :size="14" /> Changes Requested</span>
112
+ <span class="memo-reply-author">{{ r.created_by }}</span>
113
+ <span class="memo-reply-time">{{ formatTime(r.created_at) }}</span>
114
+ <button
115
+ v-if="r.created_by === authUser"
116
+ class="memo-action-btn memo-action-btn--delete"
117
+ @click="emit('deleteReply', r.id)"
118
+ title="Delete reply"
119
+ style="margin-left:auto;padding:1px 4px;font-size:10px;"
120
+ ><Icon name="trash" :size="14" /></button>
121
+ </div>
122
+ <div class="memo-reply-text" v-html="parseMentions(r.content)" @click="handleMentionClick"></div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Reply Input -->
127
+ <div v-if="replyOpenId === memo.id" class="memo-reply-input">
128
+ <div class="memo-review-type-select">
129
+ <label class="review-type-option" :class="{ active: replyReviewType === 'comment' }">
130
+ <input type="radio" name="replyReviewType" :checked="replyReviewType === 'comment'" @change="emit('update:replyReviewType', 'comment')" /> <Icon name="messageCircle" :size="14" /> Comment
131
+ </label>
132
+ <label class="review-type-option" :class="{ active: replyReviewType === 'approve' }">
133
+ <input type="radio" name="replyReviewType" :checked="replyReviewType === 'approve'" @change="emit('update:replyReviewType', 'approve')" /> <Icon name="check" :size="14" /> Approve
134
+ </label>
135
+ <label class="review-type-option" :class="{ active: replyReviewType === 'request_changes' }">
136
+ <input type="radio" name="replyReviewType" :checked="replyReviewType === 'request_changes'" @change="emit('update:replyReviewType', 'request_changes')" /> <Icon name="refreshCw" :size="14" /> Request Changes
137
+ </label>
138
+ </div>
139
+ <MentionInput
140
+ :model-value="replyText"
141
+ placeholder="Write a reply... (@ to mention)"
142
+ :rows="2"
143
+ @update:model-value="emit('update:replyText', $event)"
144
+ @submit="emit('addReply', memo.id)"
145
+ />
146
+ <div class="memo-reply-input-actions">
147
+ <button class="memo-reply-send" @click="emit('addReply', memo.id)" :disabled="!replyText.trim()">Send</button>
148
+ <button class="memo-reply-cancel" @click="emit('toggleReply', memo.id)">Cancel</button>
149
+ </div>
150
+ </div>
151
+
152
+ <div class="memo-item-actions">
153
+ <button
154
+ class="memo-action-btn memo-action-btn--reply"
155
+ @click="emit('toggleReply', memo.id)"
156
+ title="Reply"
157
+ ><Icon name="messageCircle" :size="14" /> {{ replies.length || '' }}</button>
158
+ <button
159
+ v-if="memo.status === 'open'"
160
+ class="memo-action-btn memo-action-btn--convert"
161
+ @click="emit('convertToTask', memo)"
162
+ title="Convert to task"
163
+ ><Icon name="sprint" :size="14" /> Convert to Task</button>
164
+ <button
165
+ v-if="memo.status === 'open'"
166
+ class="memo-action-btn memo-action-btn--initiative"
167
+ @click="emit('convertToInitiative', memo)"
168
+ title="Convert to initiative"
169
+ >Convert to Initiative</button>
170
+ <button
171
+ v-if="memo.status === 'open'"
172
+ class="memo-action-btn memo-action-btn--resolve"
173
+ @click="emit('resolve', memo.id)"
174
+ title="Mark as resolved"
175
+ >✓ resolve</button>
176
+ <button
177
+ v-else
178
+ class="memo-action-btn memo-action-btn--reopen"
179
+ @click="emit('reopen', memo.id)"
180
+ title="Reopen"
181
+ >↩ reopen</button>
182
+ <button
183
+ v-if="memo.author === authUser"
184
+ class="memo-action-btn memo-action-btn--delete"
185
+ @click="emit('delete', memo.id)"
186
+ title="Delete"
187
+ ><Icon name="trash" :size="14" /></button>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </template>
192
+
193
+ <style scoped>
194
+ .memo-item {
195
+ display: flex; border-radius: 8px; margin-bottom: 10px;
196
+ background: #f8fafc; border: 1px solid #e2e8f0;
197
+ overflow: hidden; transition: opacity 0.2s;
198
+ }
199
+ .memo-item--resolved { opacity: 0.6; }
200
+ .memo-item-type-bar { width: 3px; flex-shrink: 0; }
201
+ .memo-item-content { flex: 1; padding: 10px 12px; min-width: 0; }
202
+
203
+ .memo-item-header {
204
+ display: flex; align-items: center; justify-content: space-between;
205
+ margin-bottom: 4px;
206
+ }
207
+ .memo-item-type-label { font-size: 10px; font-weight: 600; color: #64748b; }
208
+ .memo-item-time { font-size: 10px; color: #94a3b8; }
209
+
210
+ .memo-item-route {
211
+ display: flex; align-items: center; gap: 4px;
212
+ margin-bottom: 6px; font-size: 12px;
213
+ }
214
+ .memo-item-author { color: #3b82f6; font-weight: 600; }
215
+ .memo-item-arrow { color: #94a3b8; }
216
+ .memo-item-assigned { color: #1e293b; font-weight: 500; }
217
+ .memo-item-assigned--all { color: #94a3b8; font-weight: 400; }
218
+ .memo-item-recipient-tag {
219
+ display: inline-block;
220
+ padding: 1px 7px; background: #dbeafe; color: #1d4ed8;
221
+ font-size: 11px; font-weight: 600; border-radius: 10px;
222
+ margin-right: 2px;
223
+ }
224
+
225
+ .memo-item-title {
226
+ font-size: 14px;
227
+ font-weight: 600;
228
+ color: var(--text-primary);
229
+ margin-bottom: 4px;
230
+ }
231
+ .memo-item-text {
232
+ font-size: 13px; color: #1e293b; line-height: 1.6;
233
+ white-space: pre-wrap; word-break: break-word;
234
+ }
235
+ .memo-item-text--resolved { text-decoration: line-through; color: #94a3b8; }
236
+
237
+ .memo-item-actions {
238
+ display: flex; gap: 6px; margin-top: 8px; justify-content: flex-end;
239
+ }
240
+ .memo-action-btn {
241
+ background: none; border: 1px solid transparent; font-size: 11px;
242
+ padding: 3px 8px; border-radius: 4px; cursor: pointer; font-weight: 500;
243
+ transition: all 0.15s;
244
+ }
245
+ .memo-action-btn--resolve { color: #22c55e; border-color: #bbf7d0; }
246
+ .memo-action-btn--resolve:hover { background: #f0fdf4; }
247
+ .memo-action-btn--reopen { color: #3b82f6; border-color: #bfdbfe; }
248
+ .memo-action-btn--reopen:hover { background: #eff6ff; }
249
+ .memo-action-btn--delete { color: #cbd5e1; border-color: transparent; }
250
+ .memo-action-btn--delete:hover { color: #ef4444; }
251
+ .memo-action-btn--reply { color: #3b82f6; border-color: #bfdbfe; }
252
+ .memo-action-btn--reply:hover { background: #eff6ff; }
253
+ .memo-action-btn--convert { color: #f59e0b; border-color: #fde68a; }
254
+ .memo-action-btn--convert:hover { background: #fffbeb; }
255
+ .memo-action-btn--initiative { color: #1d4ed8; border-color: #bfdbfe; }
256
+ .memo-action-btn--initiative:hover { background: #eff6ff; }
257
+
258
+ /* Reply */
259
+ .memo-reply-list {
260
+ margin-top: 8px; padding-left: 10px; border-left: 2px solid #e2e8f0;
261
+ }
262
+ .memo-reply-item { padding: 6px 0; font-size: 12px; }
263
+ .memo-reply-item + .memo-reply-item { border-top: 1px solid #f1f5f9; }
264
+ .memo-reply-header {
265
+ display: flex; align-items: center; gap: 6px; margin-bottom: 2px;
266
+ }
267
+ .memo-reply-author { color: #3b82f6; font-weight: 600; font-size: 11px; }
268
+ .memo-reply-time { color: #94a3b8; font-size: 10px; }
269
+ .memo-reply-text { color: #334155; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
270
+
271
+ .memo-reply-input { margin-top: 8px; }
272
+ .memo-reply-textarea {
273
+ width: 100%; border: 1px solid #e2e8f0; border-radius: 6px;
274
+ padding: 8px 10px; font-size: 12px; line-height: 1.4;
275
+ resize: none; font-family: inherit; color: #1e293b; box-sizing: border-box;
276
+ }
277
+ .memo-reply-textarea:focus { outline: none; border-color: #3b82f6; }
278
+ .memo-reply-input-actions { display: flex; gap: 6px; margin-top: 4px; justify-content: flex-end; }
279
+ .memo-reply-send {
280
+ padding: 4px 12px; background: #1e293b; color: #fff; border: none;
281
+ border-radius: 4px; font-size: 11px; font-weight: 600; cursor: pointer;
282
+ }
283
+ .memo-reply-send:hover { background: #334155; }
284
+ .memo-reply-send:disabled { background: #cbd5e1; cursor: not-allowed; }
285
+ .memo-reply-cancel {
286
+ padding: 4px 10px; background: none; border: 1px solid #e2e8f0;
287
+ border-radius: 4px; font-size: 11px; color: #94a3b8; cursor: pointer;
288
+ }
289
+ .memo-reply-cancel:hover { color: #64748b; border-color: #94a3b8; }
290
+
291
+ /* Page label (global inbox) */
292
+ .memo-item-page {
293
+ display: inline-block;
294
+ font-size: 10px; font-weight: 600; color: #3b82f6;
295
+ background: #eff6ff; padding: 2px 8px; border-radius: 10px;
296
+ margin-bottom: 4px; cursor: pointer;
297
+ }
298
+ .memo-item-page:hover { background: #dbeafe; }
299
+
300
+ /* Mention links (v-html injected) */
301
+ .memo-item-text :deep(.memo-mention),
302
+ .memo-reply-text :deep(.memo-mention) {
303
+ color: #3b82f6; font-weight: 600; text-decoration: none;
304
+ background: #eff6ff; padding: 1px 4px; border-radius: 3px;
305
+ cursor: pointer;
306
+ }
307
+ .memo-item-text :deep(.memo-mention:hover),
308
+ .memo-reply-text :deep(.memo-mention:hover) {
309
+ background: #dbeafe; text-decoration: underline;
310
+ }
311
+ .memo-item-text :deep(.mention-chip),
312
+ .memo-reply-text :deep(.mention-chip) {
313
+ background: #dbeafe; color: #1d4ed8; padding: 1px 4px; border-radius: 4px; font-weight: 500;
314
+ }
315
+ .memo-review-type-select {
316
+ display: flex;
317
+ gap: 6px;
318
+ margin-bottom: 6px;
319
+ flex-wrap: wrap;
320
+ }
321
+ .review-type-option {
322
+ font-size: 10px;
323
+ padding: 2px 6px;
324
+ border: 1px solid #e2e8f0;
325
+ border-radius: 3px;
326
+ cursor: pointer;
327
+ display: flex;
328
+ align-items: center;
329
+ gap: 3px;
330
+ transition: all 0.1s;
331
+ }
332
+ .review-type-option input[type="radio"] {
333
+ position: absolute;
334
+ width: 1px; height: 1px;
335
+ padding: 0; margin: -1px;
336
+ overflow: hidden;
337
+ clip: rect(0, 0, 0, 0);
338
+ white-space: nowrap; border: 0;
339
+ }
340
+ .review-type-option.active { border-color: #8b5cf6; background: #f5f3ff; color: #7c3aed; font-weight: 600; }
341
+
342
+ .memo-review-badge {
343
+ font-size: 10px;
344
+ font-weight: 600;
345
+ padding: 1px 5px;
346
+ border-radius: 3px;
347
+ margin-right: 4px;
348
+ }
349
+ .badge-approve { background: #dcfce7; color: #16a34a; }
350
+ .badge-changes { background: #fef3c7; color: #d97706; }
351
+ .reply-approve { border-left-color: #22c55e; }
352
+ .reply-request-changes { border-left-color: #f59e0b; }
353
+ </style>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet, apiPost, apiDelete } from '@/composables/useTurso'
5
+
6
+ interface Relation { id: number; source_memo_id: number; target_memo_id: number; relation_type: string; created_by: string }
7
+
8
+ const props = defineProps<{ memoId: number }>()
9
+ const router = useRouter()
10
+
11
+ const relations = ref<Relation[]>([])
12
+ const showModal = ref(false)
13
+ const searchQuery = ref('')
14
+ const searchResults = ref<any[]>([])
15
+ const selectedRelType = ref('related')
16
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
17
+
18
+ async function loadRelations() {
19
+ const { data } = await apiGet<{ relations: Relation[] }>(`/api/v2/memos/${props.memoId}/relations`)
20
+ relations.value = data?.relations || []
21
+ }
22
+
23
+ async function searchMemos() {
24
+ if (searchTimer) clearTimeout(searchTimer)
25
+ if (!searchQuery.value.trim()) { searchResults.value = []; return }
26
+ searchTimer = setTimeout(async () => {
27
+ const { data } = await apiGet<{ results: any[] }>(`/api/v2/search?q=${encodeURIComponent(searchQuery.value)}&type=memo`)
28
+ searchResults.value = (data?.results || []).filter((r: any) => r.id !== props.memoId)
29
+ }, 300)
30
+ }
31
+
32
+ async function addRelation(targetId: number) {
33
+ await apiPost(`/api/v2/memos/${props.memoId}/relations`, { targetMemoId: targetId, relationType: selectedRelType.value })
34
+ showModal.value = false; searchQuery.value = ''; searchResults.value = []
35
+ await loadRelations()
36
+ }
37
+
38
+ async function removeRelation(relId: number) {
39
+ if (!confirm('Delete this relation?')) return
40
+ await apiDelete(`/api/v2/memos/relations/${relId}`)
41
+ await loadRelations()
42
+ }
43
+
44
+ function getLinkedMemoId(r: Relation): number {
45
+ return r.source_memo_id === props.memoId ? r.target_memo_id : r.source_memo_id
46
+ }
47
+
48
+ const typeLabels: Record<string, string> = { related: 'Related', blocks: 'Blocks', duplicate: 'Duplicate' }
49
+
50
+ onMounted(loadRelations)
51
+ </script>
52
+
53
+ <template>
54
+ <div class="memo-relations">
55
+ <div class="rel-header">
56
+ <span class="rel-title">Related Memos ({{ relations.length }})</span>
57
+ <button class="btn btn--xs btn--ghost" @click="showModal = true">+ Link</button>
58
+ </div>
59
+ <div v-for="r in relations" :key="r.id" class="rel-item">
60
+ <span class="rel-type-badge">{{ typeLabels[r.relation_type] || r.relation_type }}</span>
61
+ <span class="rel-link" @click="router.push({ query: { memo: String(getLinkedMemoId(r)) } })">#{{ getLinkedMemoId(r) }}</span>
62
+ <button class="rel-remove" @click="removeRelation(r.id)">✕</button>
63
+ </div>
64
+
65
+ <!-- Link modal -->
66
+ <div v-if="showModal" class="rel-modal-overlay" @click.self="showModal = false">
67
+ <div class="rel-modal">
68
+ <h4>Link Memo</h4>
69
+ <select v-model="selectedRelType" class="rel-type-select">
70
+ <option value="related">Related</option>
71
+ <option value="blocks">Blocks</option>
72
+ <option value="duplicate">Duplicate</option>
73
+ </select>
74
+ <input v-model="searchQuery" class="rel-search" placeholder="Search memos..." @input="searchMemos" autofocus />
75
+ <div v-for="m in searchResults" :key="m.id" class="rel-search-item" @click="addRelation(m.id)">
76
+ #{{ m.id }} {{ m.title || m.preview }}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <style scoped>
84
+ .memo-relations { margin: 16px 0; padding: 12px; background: #fafafa; border-radius: 8px; }
85
+ .rel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
86
+ .rel-title { font-size: 13px; font-weight: 600; }
87
+ .rel-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
88
+ .rel-type-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: #e5e7eb; }
89
+ .rel-link { color: #3b82f6; cursor: pointer; }
90
+ .rel-link:hover { text-decoration: underline; }
91
+ .rel-remove { border: none; background: none; color: #9ca3af; cursor: pointer; font-size: 12px; }
92
+ .rel-remove:hover { color: #ef4444; }
93
+ .rel-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 9999; display: flex; align-items: center; justify-content: center; }
94
+ .rel-modal { background: #fff; border-radius: 12px; padding: 20px; width: 360px; max-height: 400px; overflow-y: auto; }
95
+ .rel-modal h4 { margin: 0 0 12px; }
96
+ .rel-type-select { width: 100%; border: 1px solid #d1d5db; border-radius: 6px; padding: 6px; font-size: 13px; margin-bottom: 8px; }
97
+ .rel-search { width: 100%; border: 1px solid #d1d5db; border-radius: 6px; padding: 8px; font-size: 13px; box-sizing: border-box; margin-bottom: 8px; }
98
+ .rel-search-item { padding: 8px; cursor: pointer; border-radius: 6px; font-size: 13px; }
99
+ .rel-search-item:hover { background: #eff6ff; }
100
+ @media (max-width: 767px) { .btn { min-height: 44px; } input, select { min-height: 44px; font-size: 16px; } }
101
+ </style>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, onMounted } from 'vue'
4
+ import { apiGet } from '@/composables/useTurso'
5
+ import { renderMarkdown } from '@/utils/markdown'
6
+
7
+ interface TimelineEntry { type: string; id: number; content: string; author: string; created_at: string }
8
+
9
+ const props = defineProps<{ memoId: number }>()
10
+ const timeline = ref<TimelineEntry[]>([])
11
+
12
+ async function load() {
13
+ const { data } = await apiGet<{ timeline: TimelineEntry[] }>(`/api/v2/memos/${props.memoId}/timeline`)
14
+ timeline.value = data?.timeline || []
15
+ }
16
+
17
+ const typeIcons: Record<string, string> = { reply: '<Icon name="messageCircle" :size="14" />', activity: '<Icon name="document" :size="14" />', status_change: '<Icon name="refreshCw" :size="14" />' }
18
+ const typeLabels: Record<string, string> = { reply: 'Reply', activity: 'Activity', status_change: 'Status Change' }
19
+
20
+ onMounted(load)
21
+ </script>
22
+
23
+ <template>
24
+ <div class="memo-timeline">
25
+ <div v-for="entry in timeline" :key="`${entry.type}-${entry.id}`" class="tl-entry">
26
+ <div class="tl-dot" />
27
+ <div class="tl-content">
28
+ <div class="tl-header">
29
+ <span class="tl-icon">{{ typeIcons[entry.type] || '<Icon name="pin" :size="14" />' }}</span>
30
+ <span class="tl-author">{{ entry.author }}</span>
31
+ <span class="tl-type">{{ typeLabels[entry.type] || entry.type }}</span>
32
+ <span class="tl-time">{{ entry.created_at }}</span>
33
+ </div>
34
+ <div class="tl-body" v-html="renderMarkdown(entry.content || '')"></div>
35
+ </div>
36
+ </div>
37
+ <div v-if="!timeline.length" class="tl-empty">Timeline is empty.</div>
38
+ </div>
39
+ </template>
40
+
41
+ <style scoped>
42
+ .memo-timeline { padding: 8px 0; }
43
+ .tl-entry { display: flex; gap: 12px; padding: 8px 0; border-left: 2px solid #e5e7eb; margin-left: 8px; padding-left: 16px; position: relative; }
44
+ .tl-dot { position: absolute; left: -5px; top: 12px; width: 8px; height: 8px; border-radius: 50%; background: #3b82f6; }
45
+ .tl-content { flex: 1; }
46
+ .tl-header { display: flex; gap: 8px; align-items: center; font-size: 12px; color: #6b7280; margin-bottom: 4px; }
47
+ .tl-icon { font-size: 14px; }
48
+ .tl-author { font-weight: 600; color: #374151; }
49
+ .tl-time { margin-left: auto; font-size: 11px; }
50
+ .tl-body { font-size: 13px; line-height: 1.5; }
51
+ .tl-empty { color: #9ca3af; font-size: 13px; text-align: center; padding: 24px; }
52
+ @media (max-width: 767px) { .btn { min-height: 44px; } input, select { min-height: 44px; font-size: 16px; } }
53
+ </style>
@@ -0,0 +1,174 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick, onMounted } from 'vue'
3
+ import { apiGet } from '@/composables/useTurso'
4
+
5
+ const props = defineProps<{
6
+ modelValue: string
7
+ placeholder?: string
8
+ rows?: number
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ 'update:modelValue': [value: string]
13
+ submit: []
14
+ }>()
15
+
16
+ interface Member { id: number; name: string; role: string }
17
+
18
+ const members = ref<Member[]>([])
19
+ const showSuggestions = ref(false)
20
+ const suggestions = ref<Member[]>([])
21
+ const cursorPos = ref(0)
22
+ const mentionStart = ref(-1)
23
+ const selectedIdx = ref(0)
24
+ const textareaRef = ref<HTMLTextAreaElement>()
25
+
26
+ onMounted(() => loadMembers())
27
+
28
+ async function loadMembers() {
29
+ if (members.value.length) return
30
+ const { data } = await apiGet<{ members: any[] }>('/api/v2/admin/members')
31
+ if (data?.members) {
32
+ members.value = data.members
33
+ .filter((m: any) => m.is_active)
34
+ .map((m: any) => ({ id: m.id, name: m.display_name || m.name || '', role: m.role || '' }))
35
+ }
36
+ }
37
+
38
+ async function onInput(e: Event) {
39
+ const el = e.target as HTMLTextAreaElement
40
+ const val = el.value
41
+ emit('update:modelValue', val)
42
+ cursorPos.value = el.selectionStart || 0
43
+ await checkMention(val, cursorPos.value)
44
+ }
45
+
46
+ async function checkMention(text: string, pos: number) {
47
+ // Find text after @
48
+ const before = text.slice(0, pos)
49
+ const atIdx = before.lastIndexOf('@')
50
+ if (atIdx === -1 || (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n')) {
51
+ showSuggestions.value = false
52
+ return
53
+ }
54
+ const query = before.slice(atIdx + 1).toLowerCase()
55
+ if (query.includes(' ') || query.includes('\n')) {
56
+ showSuggestions.value = false
57
+ return
58
+ }
59
+ mentionStart.value = atIdx
60
+ await loadMembers()
61
+ suggestions.value = members.value.filter(m =>
62
+ m.name.toLowerCase().includes(query)
63
+ ).slice(0, 5)
64
+ showSuggestions.value = suggestions.value.length > 0
65
+ selectedIdx.value = 0
66
+ }
67
+
68
+ function selectMember(m: Member) {
69
+ const text = props.modelValue
70
+ const before = text.slice(0, mentionStart.value)
71
+ const after = text.slice(cursorPos.value)
72
+ const newText = `${before}@${m.name} ${after}`
73
+ emit('update:modelValue', newText)
74
+ showSuggestions.value = false
75
+ nextTick(() => {
76
+ if (textareaRef.value) {
77
+ const newPos = mentionStart.value + m.name.length + 2
78
+ textareaRef.value.focus()
79
+ textareaRef.value.setSelectionRange(newPos, newPos)
80
+ }
81
+ })
82
+ }
83
+
84
+ const isComposing = ref(false)
85
+
86
+ function onKeydown(e: KeyboardEvent) {
87
+ if (e.key === 'Enter' && !e.shiftKey && !showSuggestions.value && !isComposing.value) {
88
+ e.preventDefault()
89
+ emit('submit')
90
+ }
91
+ if (e.key === 'Escape') {
92
+ showSuggestions.value = false
93
+ }
94
+ // Keyboard navigation
95
+ if (showSuggestions.value) {
96
+ if (e.key === 'ArrowDown') {
97
+ e.preventDefault()
98
+ selectedIdx.value = Math.min(selectedIdx.value + 1, suggestions.value.length - 1)
99
+ } else if (e.key === 'ArrowUp') {
100
+ e.preventDefault()
101
+ selectedIdx.value = Math.max(selectedIdx.value - 1, 0)
102
+ } else if ((e.key === 'Tab' || e.key === 'Enter') && suggestions.value.length > 0) {
103
+ e.preventDefault()
104
+ selectMember(suggestions.value[selectedIdx.value])
105
+ }
106
+ }
107
+ }
108
+ </script>
109
+
110
+ <template>
111
+ <div class="mention-input-wrap">
112
+ <textarea
113
+ ref="textareaRef"
114
+ :value="modelValue"
115
+ :placeholder="placeholder || 'Write a reply... (@ to mention)'"
116
+ :rows="rows || 3"
117
+ @input="onInput"
118
+ @compositionstart="isComposing = true"
119
+ @compositionend="isComposing = false; onInput($event)"
120
+ @keydown="onKeydown"
121
+ />
122
+ <div v-if="showSuggestions" class="mention-suggestions">
123
+ <div
124
+ v-for="(m, i) in suggestions"
125
+ :key="m.id"
126
+ class="mention-item"
127
+ :class="{ 'mention-item--selected': i === selectedIdx }"
128
+ @mousedown.prevent="selectMember(m)"
129
+ @touchend.prevent="selectMember(m)"
130
+ >
131
+ <span class="mention-name">{{ m.name }}</span>
132
+ <span class="mention-role">{{ m.role }}</span>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </template>
137
+
138
+ <style scoped>
139
+ .mention-input-wrap { position: relative; overflow: visible; }
140
+ .mention-input-wrap textarea {
141
+ width: 100%;
142
+ border: 1px solid rgba(0,0,0,0.15);
143
+ border-radius: 8px;
144
+ padding: 8px;
145
+ font-size: 13px;
146
+ resize: vertical;
147
+ box-sizing: border-box;
148
+ }
149
+ .mention-suggestions {
150
+ position: absolute;
151
+ bottom: calc(100% + 4px);
152
+ left: 0;
153
+ right: 0;
154
+ min-width: 200px;
155
+ background: #fff;
156
+ border: 1px solid rgba(0,0,0,0.15);
157
+ border-radius: 8px;
158
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
159
+ z-index: 1000;
160
+ max-height: 160px;
161
+ overflow-y: auto;
162
+ }
163
+ .mention-item {
164
+ padding: 8px 12px;
165
+ cursor: pointer;
166
+ display: flex;
167
+ justify-content: space-between;
168
+ align-items: center;
169
+ font-size: 13px;
170
+ }
171
+ .mention-item:hover, .mention-item--selected { background: #f0f4ff; }
172
+ .mention-name { font-weight: 600; }
173
+ .mention-role { font-size: 11px; color: #888; }
174
+ </style>