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,309 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet, apiPut, apiPatch } from '@/composables/useTurso'
5
+ import TreeNode from './TreeNode.vue'
6
+ import Icon from './Icon.vue'
7
+
8
+ interface DocNode {
9
+ id: string; title: string; icon: string | null; is_folder: number
10
+ parent_id: string | null; sort_order: number; children: DocNode[]
11
+ }
12
+
13
+ const props = defineProps<{ activeDocId?: string }>()
14
+ const emit = defineEmits<{ refresh: [] }>()
15
+ const router = useRouter()
16
+
17
+ const tree = ref<DocNode[]>([])
18
+ const expanded = ref<Set<string>>(new Set())
19
+ const searchQuery = ref('')
20
+ const searchResults = ref<any[]>([])
21
+ const selectedTag = ref('')
22
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
23
+
24
+ async function onSearch() {
25
+ if (searchTimer) clearTimeout(searchTimer)
26
+ if (!searchQuery.value.trim()) { searchResults.value = []; return }
27
+ searchTimer = setTimeout(async () => {
28
+ const { data } = await apiGet<{ results: any[] }>(`/api/v2/docs/search?q=${encodeURIComponent(searchQuery.value)}`)
29
+ searchResults.value = data?.results || []
30
+ }, 300)
31
+ }
32
+
33
+ const allTags = computed(() => {
34
+ const tags = new Set<string>()
35
+ function collect(nodes: DocNode[]) {
36
+ for (const n of nodes) {
37
+ if ((n as any).tags) {
38
+ try { JSON.parse((n as any).tags).forEach((t: string) => tags.add(t)) } catch {}
39
+ }
40
+ if (n.children) collect(n.children)
41
+ }
42
+ }
43
+ collect(tree.value)
44
+ return Array.from(tags)
45
+ })
46
+ const creatingFolder = ref(false)
47
+ const newFolderName = ref('')
48
+ const dragId = ref('')
49
+ const ctxMenu = ref<{ x: number; y: number; node: any } | null>(null)
50
+ const renamingId = ref<string | null>(null)
51
+ const renameText = ref('')
52
+
53
+ function onCtxMenu(e: MouseEvent, node: any) {
54
+ // Adjust scroll position + prevent overflow off-screen
55
+ const menuH = 120 // estimated menu height
56
+ const y = Math.min(e.clientY, window.innerHeight - menuH)
57
+ const x = Math.min(e.clientX, window.innerWidth - 160)
58
+ ctxMenu.value = { x, y, node }
59
+ }
60
+
61
+ function closeCtxMenu() { ctxMenu.value = null }
62
+
63
+ // Bulk MD file upload
64
+ const uploadProgress = ref({ current: 0, total: 0 })
65
+
66
+ async function bulkUploadMd(e: Event) {
67
+ const input = e.target as HTMLInputElement
68
+ const files = input.files
69
+ if (!files?.length) return
70
+ const fileArr = Array.from(files)
71
+ uploadProgress.value = { current: 0, total: fileArr.length }
72
+
73
+ // Parent ID of current selected folder
74
+ const parentId = props.activeDocId || null
75
+ let count = 0
76
+ // Fetch existing sibling titles from BE
77
+ const { data: sibData } = await apiGet(`/api/v2/docs/children?parentId=${parentId || ''}`)
78
+ const existingTitles = new Set(((sibData as any)?.docs || []).map((d: any) => d.title))
79
+
80
+ for (const file of fileArr) {
81
+ const text = await file.text()
82
+ let title = file.name.replace(/\.(md|txt)$/i, '')
83
+ // Duplicate title suffix
84
+ let suffix = 1
85
+ while (existingTitles.has(title)) { title = `${file.name.replace(/\.(md|txt)$/i, '')} (${suffix++})` }
86
+ existingTitles.add(title)
87
+
88
+ const slug = `upload-${Date.now()}-${count}`
89
+ const { error } = await apiPut(`/api/v2/docs/${slug}`, { title, content: text, contentFormat: 'markdown', parentId })
90
+ if (!error) count++
91
+ uploadProgress.value.current = uploadProgress.value.current + 1
92
+ }
93
+ input.value = ''
94
+ const failed = fileArr.length - count
95
+ uploadProgress.value = { current: 0, total: 0 }
96
+ alert(`${count} uploaded successfully${failed ? `, ${failed} failed` : ''}`)
97
+ await loadTree()
98
+ }
99
+
100
+ async function ctxDelete() {
101
+ if (!ctxMenu.value || !confirm('Delete this document?')) return
102
+ await apiPatch(`/api/v2/docs/${ctxMenu.value.node.id}`, { archived: 1 })
103
+ closeCtxMenu(); await loadTree()
104
+ }
105
+
106
+ function ctxRename() {
107
+ if (!ctxMenu.value) return
108
+ renamingId.value = ctxMenu.value.node.id
109
+ renameText.value = ctxMenu.value.node.title
110
+ closeCtxMenu()
111
+ }
112
+
113
+ async function submitRename() {
114
+ if (!renamingId.value || !renameText.value.trim()) return
115
+ await apiPatch(`/api/v2/docs/${renamingId.value}`, { title: renameText.value.trim() })
116
+ renamingId.value = null; await loadTree()
117
+ }
118
+
119
+ async function ctxNewChild() {
120
+ if (!ctxMenu.value) return
121
+ const slug = `doc-${Date.now()}`
122
+ await apiPut(`/api/v2/docs/${slug}`, { title: 'New Document', content: '' })
123
+ await apiPatch(`/api/v2/docs/${slug}/move`, { parentId: ctxMenu.value.node.id })
124
+ closeCtxMenu(); await loadTree()
125
+ router.push(`/docs/${slug}`)
126
+ }
127
+
128
+ async function loadTree() {
129
+ const { data } = await apiGet<{ tree: DocNode[] }>('/api/v2/docs/tree')
130
+ tree.value = data?.tree || []
131
+ // Auto-expand: parents of the current document
132
+ if (props.activeDocId) expandParents(tree.value, props.activeDocId)
133
+ }
134
+
135
+ function expandParents(nodes: DocNode[], targetId: string): boolean {
136
+ for (const n of nodes) {
137
+ if (n.id === targetId) return true
138
+ if (n.children.length && expandParents(n.children, targetId)) {
139
+ expanded.value.add(n.id)
140
+ return true
141
+ }
142
+ }
143
+ return false
144
+ }
145
+
146
+ function toggle(id: string) {
147
+ if (expanded.value.has(id)) expanded.value.delete(id)
148
+ else expanded.value.add(id)
149
+ }
150
+
151
+ function navigate(id: string) {
152
+ router.push(`/docs/${id}`)
153
+ }
154
+
155
+ async function createFolder() {
156
+ if (!newFolderName.value.trim()) return
157
+ const slug = newFolderName.value.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
158
+ await apiPut('/api/v2/docs/' + slug, { title: newFolderName.value.trim(), content: '' })
159
+ newFolderName.value = ''
160
+ creatingFolder.value = false
161
+ await loadTree()
162
+ }
163
+
164
+ function onDragStart(e: DragEvent, id: string) {
165
+ dragId.value = id
166
+ e.dataTransfer?.setData('doc-id', id)
167
+ }
168
+
169
+ async function onDropRoot(e: DragEvent) {
170
+ e.preventDefault()
171
+ const sourceId = e.dataTransfer?.getData('doc-id') || dragId.value
172
+ if (!sourceId) return
173
+ await apiPatch(`/api/v2/docs/${sourceId}/move`, { parentId: null })
174
+ await loadTree(); dragId.value = ''
175
+ }
176
+
177
+ async function onDrop(e: DragEvent, target: DocNode) {
178
+ e.preventDefault()
179
+ const sourceId = e.dataTransfer?.getData('doc-id') || dragId.value
180
+ if (!sourceId || sourceId === target.id) return
181
+
182
+ await apiPatch(`/api/v2/docs/${sourceId}/move`, { parentId: target.id })
183
+ await loadTree()
184
+ dragId.value = ''
185
+ }
186
+
187
+ // Sidebar tree polling (60s)
188
+ let treePollTimer: ReturnType<typeof setInterval> | null = null
189
+ onMounted(() => { loadTree(); treePollTimer = setInterval(loadTree, 60000) })
190
+ onUnmounted(() => { if (treePollTimer) clearInterval(treePollTimer) })
191
+ </script>
192
+
193
+ <template>
194
+ <div class="docs-sidebar">
195
+ <div class="sidebar-header">
196
+ <span class="sidebar-title">Documents</span>
197
+ <button class="sidebar-btn" @click="creatingFolder = true" title="New folder"><Icon name="folderPlus" :size="16" /></button>
198
+ <button class="sidebar-btn" @click="router.push('/docs/new')" title="New document"><Icon name="filePlus" :size="16" /></button>
199
+ <!-- Bulk MD file upload -->
200
+ <label class="sidebar-btn" title="Upload MD files">
201
+ <Icon name="upload" :size="16" />
202
+ <input type="file" accept=".md,.txt" multiple hidden @change="bulkUploadMd" />
203
+ </label>
204
+ <span v-if="uploadProgress.total" class="upload-prog">{{ uploadProgress.current }}/{{ uploadProgress.total }}</span>
205
+ </div>
206
+
207
+ <div v-if="creatingFolder" class="folder-input-wrap">
208
+ <input v-model="newFolderName" class="folder-input" placeholder="Folder name" @keyup.enter="createFolder" autofocus />
209
+ <button class="sidebar-btn" @click="createFolder">&#10003;</button>
210
+ </div>
211
+
212
+ <!-- Search -->
213
+ <div class="sidebar-search">
214
+ <input v-model="searchQuery" class="search-input" placeholder="Search..." @input="onSearch" />
215
+ </div>
216
+
217
+ <!-- Search results -->
218
+ <div v-if="searchResults.length" class="search-results">
219
+ <div v-for="r in searchResults" :key="r.id" class="search-item" @click="navigate(r.id)">
220
+ <span class="search-icon">{{ (r.icon && !r.icon.startsWith('Icon') && !r.icon.startsWith('<')) ? r.icon : '📄' }}</span>
221
+ <div>
222
+ <div class="search-title">{{ r.title }}</div>
223
+ <div class="search-snippet">{{ r.snippet }}</div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Tag filter -->
229
+ <div v-if="allTags.length && !searchQuery" class="tag-filter">
230
+ <span v-for="tag in allTags" :key="tag" class="tag-chip" :class="{ active: selectedTag === tag }" @click="selectedTag = selectedTag === tag ? '' : tag">{{ tag }}</span>
231
+ </div>
232
+
233
+ <!-- Root drop zone -->
234
+ <div class="root-drop" @dragover.prevent @drop.prevent.stop="onDropRoot">Move to root</div>
235
+
236
+ <div class="tree-list">
237
+ <TreeNode
238
+ v-for="node in tree"
239
+ :key="node.id"
240
+ :node="node"
241
+ :active-doc-id="activeDocId"
242
+ :expanded="expanded"
243
+ :depth="0"
244
+ @toggle="toggle"
245
+ @dragstart="onDragStart"
246
+ @drop="onDrop"
247
+ @contextmenu="onCtxMenu"
248
+ />
249
+ </div>
250
+ <!-- Context menu -->
251
+ <div v-if="ctxMenu" class="ctx-overlay" @click="closeCtxMenu" />
252
+ <div v-if="ctxMenu" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
253
+ <div class="ctx-item" @click="ctxNewChild"><Icon name="filePlus" :size="14" /> New sub-document</div>
254
+ <div class="ctx-item" @click="ctxRename"><Icon name="pencil" :size="14" /> Rename</div>
255
+ <div class="ctx-item ctx-danger" @click="ctxDelete"><Icon name="trash" :size="14" /> Delete</div>
256
+ </div>
257
+
258
+ <!-- Rename inline -->
259
+ <div v-if="renamingId" class="rename-overlay" @click.self="renamingId = null">
260
+ <div class="rename-box">
261
+ <input v-model="renameText" class="rename-input" @keyup.enter="submitRename" autofocus />
262
+ <button class="btn btn--xs btn--primary" @click="submitRename">OK</button>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </template>
267
+
268
+ <style scoped>
269
+ .docs-sidebar { width: 260px; min-width: 260px; height: 100%; overflow-y: auto; background: var(--bg-sidebar, #2b2d36); display: flex; flex-direction: column; color: var(--text-sidebar, #d1d5db); }
270
+ .sidebar-header { display: flex; align-items: center; padding: 12px 16px; gap: 4px; border-bottom: 1px solid rgba(255,255,255,0.08); }
271
+ .tree-list { flex: 1; overflow-y: auto; padding: 8px 0; }
272
+ .sidebar-title { font-size: 13px; font-weight: 700; flex: 1; }
273
+ .sidebar-btn { border: none; background: none; font-size: 14px; cursor: pointer; padding: 2px 4px; border-radius: 4px; color: var(--text-sidebar); }
274
+ .sidebar-btn:hover { background: rgba(255,255,255,0.08); }
275
+ .folder-input-wrap { display: flex; gap: 4px; padding: 8px 12px; }
276
+ .folder-input { flex: 1; border: 1px solid #d1d5db; border-radius: 4px; padding: 4px 8px; font-size: 12px; }
277
+ .tree-list { padding: 4px 0; }
278
+ .tree-item { display: flex; align-items: center; gap: 4px; padding: 5px 12px; font-size: 13px; cursor: pointer; border-radius: 4px; margin: 1px 4px; }
279
+ .tree-item:hover { background: #f3f4f6; }
280
+ .tree-item.active { background: #eff6ff; color: #3b82f6; font-weight: 600; }
281
+ .tree-arrow { font-size: 10px; width: 14px; text-align: center; }
282
+ .tree-icon { font-size: 14px; }
283
+ .tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
284
+ .tree-children { padding-left: 16px; }
285
+ .tree-item.child { font-size: 12px; }
286
+ .root-drop { padding: 6px 12px; text-align: center; font-size: 11px; color: #9ca3af; border: 1px dashed #d1d5db; border-radius: 4px; margin: 4px 8px; }
287
+ .root-drop:hover { background: #f3f4f6; border-color: #3b82f6; }
288
+ .sidebar-search { padding: 8px 12px; }
289
+ .search-input { width: 100%; border: none; background: rgba(255,255,255,0.08); border-radius: 6px; padding: 8px 10px; font-size: 12px; box-sizing: border-box; outline: none; transition: box-shadow 0.15s; color: #fff; }
290
+ .search-input:focus { box-shadow: 0 0 0 2px var(--primary); background: rgba(255,255,255,0.12); }
291
+ .search-input::placeholder { color: rgba(255,255,255,0.4); }
292
+ .search-results { padding: 0 8px; }
293
+ .search-item { display: flex; gap: 6px; padding: 6px 4px; cursor: pointer; border-radius: 4px; }
294
+ .search-item:hover { background: #f3f4f6; }
295
+ .search-icon { font-size: 14px; }
296
+ .search-title { font-size: 12px; font-weight: 600; }
297
+ .search-snippet { font-size: 10px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
298
+ .tag-filter { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 12px; }
299
+ .tag-chip { font-size: 10px; padding: 2px 8px; border-radius: 10px; background: #f3f4f6; color: #6b7280; cursor: pointer; }
300
+ .tag-chip.active { background: #3b82f6; color: #fff; }
301
+ .ctx-overlay { position: fixed; inset: 0; z-index: 998; }
302
+ .ctx-menu { position: fixed; z-index: 999; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 4px 0; min-width: 140px; }
303
+ .ctx-item { padding: 6px 12px; font-size: 12px; cursor: pointer; }
304
+ .ctx-item:hover { background: #f3f4f6; }
305
+ .ctx-danger { color: #ef4444; }
306
+ .rename-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.2); }
307
+ .rename-box { background: #fff; padding: 16px; border-radius: 8px; display: flex; gap: 8px; }
308
+ .rename-input { border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 10px; font-size: 13px; min-width: 200px; }
309
+ </style>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ icon?: string
4
+ title?: string
5
+ description?: string
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="empty-state">
11
+ <div v-if="icon" class="empty-icon">{{ icon }}</div>
12
+ <div class="empty-title">{{ title || 'No data' }}</div>
13
+ <div v-if="description" class="empty-desc">{{ description }}</div>
14
+ <slot />
15
+ </div>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .empty-state {
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ padding: 48px 24px;
25
+ text-align: center;
26
+ }
27
+ .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
28
+ .empty-title { font-size: 16px; font-weight: 600; color: var(--text-primary, #333); margin-bottom: 8px; }
29
+ .empty-desc { font-size: 13px; color: var(--text-muted, #888); max-width: 300px; line-height: 1.5; }
30
+ </style>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ message?: string
4
+ }>()
5
+ const emit = defineEmits<{ retry: [] }>()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="error-banner">
10
+ <span class="error-text">{{ message || 'Failed to load data' }}</span>
11
+ <button class="error-retry" @click="emit('retry')">Retry</button>
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .error-banner {
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ gap: 12px;
21
+ padding: 12px 16px;
22
+ background: #fef2f2;
23
+ border: 1px solid #fecaca;
24
+ border-radius: 12px;
25
+ margin: 16px;
26
+ }
27
+ .error-text { font-size: 13px; color: #dc2626; }
28
+ .error-retry {
29
+ padding: 4px 12px;
30
+ border: 1px solid #dc2626;
31
+ border-radius: 6px;
32
+ background: none;
33
+ color: #dc2626;
34
+ font-size: 12px;
35
+ cursor: pointer;
36
+ }
37
+ .error-retry:hover { background: #fee2e2; }
38
+ </style>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ LayoutDashboard, FileText, MessageSquare, MessageCircle, Calendar, Trophy, Settings,
4
+ ClipboardList, GitBranch, BarChart3, BarChart2, Users, Search, Plus, ChevronDown,
5
+ Check, X, Edit, Pencil, Trash2, Link, Eye, Clock, Bell,
6
+ FolderPlus, FilePlus, Moon, Sun, Home, Target, Pin, RefreshCw,
7
+ Lock, Unlock, Bug, HelpCircle, Monitor
8
+ } from 'lucide-vue-next'
9
+
10
+ const props = defineProps<{ name: string; size?: number }>()
11
+
12
+ const iconMap: Record<string, any> = {
13
+ dashboard: LayoutDashboard,
14
+ document: FileText,
15
+ memo: MessageSquare,
16
+ calendar: Calendar,
17
+ trophy: Trophy,
18
+ settings: Settings,
19
+ sprint: ClipboardList,
20
+ branch: GitBranch,
21
+ chart: BarChart3,
22
+ users: Users,
23
+ search: Search,
24
+ plus: Plus,
25
+ chevronDown: ChevronDown,
26
+ check: Check,
27
+ close: X,
28
+ edit: Edit,
29
+ trash: Trash2,
30
+ link: Link,
31
+ eye: Eye,
32
+ clock: Clock,
33
+ bell: Bell,
34
+ folderPlus: FolderPlus,
35
+ filePlus: FilePlus,
36
+ moon: Moon,
37
+ sun: Sun,
38
+ home: Home,
39
+ target: Target,
40
+ messageCircle: MessageCircle,
41
+ pin: Pin,
42
+ refreshCw: RefreshCw,
43
+ pencil: Pencil,
44
+ lock: Lock,
45
+ unlock: Unlock,
46
+ bug: Bug,
47
+ helpCircle: HelpCircle,
48
+ monitor: Monitor,
49
+ barChart2: BarChart2,
50
+ }
51
+
52
+ const IconComponent = iconMap[props.name]
53
+ </script>
54
+
55
+ <template>
56
+ <component v-if="IconComponent" :is="IconComponent" :size="size || 16" />
57
+ <span v-else>{{ name }}</span>
58
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { apiGet } from '@/api/client'
4
+
5
+ interface MemberItem { id: number; display_name: string }
6
+
7
+ const props = defineProps<{
8
+ modelValue: string[]
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ 'update:modelValue': [value: string[]]
13
+ }>()
14
+
15
+ const members = ref<MemberItem[]>([])
16
+
17
+ function toggle(name: string) {
18
+ const current = new Set(props.modelValue)
19
+ if (current.has(name)) current.delete(name)
20
+ else current.add(name)
21
+ emit('update:modelValue', [...current])
22
+ }
23
+
24
+ onMounted(async () => {
25
+ const { data } = await apiGet<{ members: MemberItem[] }>('/api/v2/admin/members')
26
+ if (data?.members) members.value = data.members.filter(m => (m as any).is_active)
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <div class="member-select">
32
+ <label v-for="m in members" :key="m.id"
33
+ class="member-tag" :class="{ selected: modelValue.includes(m.display_name) }"
34
+ @click="toggle(m.display_name)">
35
+ {{ m.display_name }}
36
+ </label>
37
+ </div>
38
+ </template>
39
+
40
+ <style scoped>
41
+ .member-select { display: flex; flex-wrap: wrap; gap: 6px; }
42
+ .member-tag {
43
+ padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
44
+ border: 1px solid rgba(0,0,0,0.08); background: rgba(0,0,0,0.02);
45
+ transition: all 0.15s; user-select: none;
46
+ }
47
+ .member-tag.selected { background: var(--primary); color: #fff; border-color: var(--primary); }
48
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { apiGet, apiPost, apiPatch, apiDelete } from '@/composables/useTurso'
4
+
5
+ interface CheckItem { id: number; content: string; assignee: string | null; is_done: number; sort_order: number }
6
+
7
+ const props = defineProps<{ memoId: number; members?: string[] }>()
8
+ const items = ref<CheckItem[]>([])
9
+ const newContent = ref('')
10
+
11
+ async function load() {
12
+ const { data } = await apiGet<{ items: CheckItem[] }>(`/api/v2/memos/${props.memoId}/checklist`)
13
+ items.value = data?.items || []
14
+ }
15
+
16
+ const progress = computed(() => {
17
+ const total = items.value.length
18
+ const done = items.value.filter(i => i.is_done).length
19
+ return { done, total, percent: total ? Math.round(done / total * 100) : 0 }
20
+ })
21
+
22
+ async function addItem() {
23
+ if (!newContent.value.trim()) return
24
+ await apiPost(`/api/v2/memos/${props.memoId}/checklist`, { content: newContent.value.trim() })
25
+ newContent.value = ''
26
+ await load()
27
+ }
28
+
29
+ async function toggleDone(item: CheckItem) {
30
+ await apiPatch(`/api/v2/memos/checklist/${item.id}`, { is_done: item.is_done ? 0 : 1 })
31
+ item.is_done = item.is_done ? 0 : 1
32
+ }
33
+
34
+ async function setAssignee(item: CheckItem, assignee: string) {
35
+ await apiPatch(`/api/v2/memos/checklist/${item.id}`, { assignee })
36
+ item.assignee = assignee
37
+ }
38
+
39
+ async function removeItem(id: number) {
40
+ await apiDelete(`/api/v2/memos/checklist/${id}`)
41
+ await load()
42
+ }
43
+
44
+ onMounted(load)
45
+ </script>
46
+
47
+ <template>
48
+ <div class="memo-checklist">
49
+ <div class="cl-header">
50
+ <span class="cl-title">Checklist</span>
51
+ <span class="cl-progress">{{ progress.done }}/{{ progress.total }} ({{ progress.percent }}%)</span>
52
+ </div>
53
+ <div class="cl-progress-bar"><div class="cl-progress-fill" :style="{ width: progress.percent + '%' }" /></div>
54
+
55
+ <div v-for="item in items" :key="item.id" class="cl-item" :class="{ done: item.is_done }">
56
+ <input type="checkbox" :checked="!!item.is_done" @change="toggleDone(item)" />
57
+ <span class="cl-content">{{ item.content }}</span>
58
+ <select class="cl-assignee" :value="item.assignee || ''" @change="setAssignee(item, ($event.target as HTMLSelectElement).value)">
59
+ <option value="">Assignee</option>
60
+ <option v-for="m in (members || [])" :key="m" :value="m">{{ m }}</option>
61
+ </select>
62
+ <button class="cl-remove" @click="removeItem(item.id)">✕</button>
63
+ </div>
64
+
65
+ <div class="cl-add">
66
+ <input v-model="newContent" class="cl-input" placeholder="Add item..." @keyup.enter="addItem" />
67
+ <button class="btn btn--xs btn--primary" @click="addItem">Add</button>
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .memo-checklist { margin: 12px 0; padding: 12px; background: #fafafa; border-radius: 8px; }
74
+ .cl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
75
+ .cl-title { font-size: 13px; font-weight: 600; }
76
+ .cl-progress { font-size: 11px; color: #6b7280; }
77
+ .cl-progress-bar { height: 4px; background: #e5e7eb; border-radius: 2px; margin-bottom: 8px; }
78
+ .cl-progress-fill { height: 100%; background: #22c55e; border-radius: 2px; transition: width 0.2s; }
79
+ .cl-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
80
+ .cl-item.done .cl-content { text-decoration: line-through; color: #9ca3af; }
81
+ .cl-content { flex: 1; }
82
+ .cl-assignee { border: 1px solid #e5e7eb; border-radius: 4px; padding: 1px 4px; font-size: 11px; }
83
+ .cl-remove { border: none; background: none; color: #9ca3af; cursor: pointer; font-size: 12px; }
84
+ .cl-remove:hover { color: #ef4444; }
85
+ .cl-add { display: flex; gap: 4px; margin-top: 8px; }
86
+ .cl-input { flex: 1; border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 8px; font-size: 12px; }
87
+ @media (max-width: 767px) { .btn { min-height: 44px; } input, select { min-height: 44px; font-size: 16px; } }
88
+ </style>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet } from '@/composables/useTurso'
5
+
6
+ interface Memo { id: number; content: string; status: string; created_by: string }
7
+ interface Relation { id: number; source_memo_id: number; target_memo_id: number; relation_type: string }
8
+
9
+ const props = defineProps<{ memos: Memo[] }>()
10
+ const router = useRouter()
11
+
12
+ const allRelations = ref<Relation[]>([])
13
+
14
+ async function loadAllRelations() {
15
+ // Batch-load relations for recent 20 memos
16
+ const recent = props.memos.slice(0, 20)
17
+ const results: Relation[] = []
18
+ for (const m of recent) {
19
+ const { data } = await apiGet<{ relations: Relation[] }>(`/api/v2/memos/${m.id}/relations`)
20
+ if (data?.relations) results.push(...data.relations)
21
+ }
22
+ // Deduplicate
23
+ const seen = new Set<number>()
24
+ allRelations.value = results.filter(r => { if (seen.has(r.id)) return false; seen.add(r.id); return true })
25
+ }
26
+
27
+ const nodes = computed(() => {
28
+ const ids = new Set<number>()
29
+ for (const r of allRelations.value) { ids.add(r.source_memo_id); ids.add(r.target_memo_id) }
30
+ return props.memos.filter(m => ids.has(m.id)).map((m, i) => ({
31
+ ...m,
32
+ x: 80 + (i % 5) * 140,
33
+ y: 60 + Math.floor(i / 5) * 100,
34
+ title: m.content.split('\n')[0].slice(0, 30),
35
+ }))
36
+ })
37
+
38
+ const edges = computed(() => {
39
+ return allRelations.value.map(r => {
40
+ const s = nodes.value.find(n => n.id === r.source_memo_id)
41
+ const t = nodes.value.find(n => n.id === r.target_memo_id)
42
+ if (!s || !t) return null
43
+ return { id: r.id, x1: s.x + 60, y1: s.y + 20, x2: t.x + 60, y2: t.y + 20, type: r.relation_type }
44
+ }).filter(Boolean) as { id: number; x1: number; y1: number; x2: number; y2: number; type: string }[]
45
+ })
46
+
47
+ const svgWidth = computed(() => Math.max(700, ...nodes.value.map(n => n.x + 140)))
48
+ const svgHeight = computed(() => Math.max(300, ...nodes.value.map(n => n.y + 80)))
49
+
50
+ const statusColors: Record<string, string> = { open: '#3b82f6', resolved: '#22c55e', 'request-changes': '#f59e0b' }
51
+
52
+ onMounted(loadAllRelations)
53
+ </script>
54
+
55
+ <template>
56
+ <div class="memo-graph">
57
+ <svg :width="svgWidth" :height="svgHeight">
58
+ <!-- edges -->
59
+ <line v-for="e in edges" :key="e.id" :x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2" stroke="#d1d5db" stroke-width="2" />
60
+ <!-- nodes -->
61
+ <g v-for="n in nodes" :key="n.id" @click="router.push({ query: { memo: String(n.id) } })" style="cursor:pointer">
62
+ <rect :x="n.x" :y="n.y" width="120" height="40" rx="8" :fill="statusColors[n.status] || '#e5e7eb'" opacity="0.15" stroke="#d1d5db" />
63
+ <text :x="n.x + 60" :y="n.y + 16" text-anchor="middle" font-size="11" font-weight="600" fill="#374151">#{{ n.id }}</text>
64
+ <text :x="n.x + 60" :y="n.y + 30" text-anchor="middle" font-size="9" fill="#6b7280">{{ n.title }}</text>
65
+ </g>
66
+ </svg>
67
+ <div v-if="!nodes.length" class="graph-empty">No connected memos found.</div>
68
+ </div>
69
+ </template>
70
+
71
+ <style scoped>
72
+ .memo-graph { overflow-x: auto; padding: 12px; background: #fafafa; border-radius: 8px; margin: 12px 0; }
73
+ .graph-empty { color: #9ca3af; text-align: center; padding: 24px; font-size: 13px; }
74
+ @media (max-width: 767px) { .btn { min-height: 44px; } input, select { min-height: 44px; font-size: 16px; } }
75
+ </style>