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,116 @@
1
+ <script setup lang="ts">
2
+ import { type NotificationItem } from '@/composables/useNotification'
3
+
4
+ defineProps<{
5
+ notifications: NotificationItem[]
6
+ unreadCount: number
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ toggle: []
11
+ click: [n: NotificationItem]
12
+ markAllRead: []
13
+ }>()
14
+
15
+ function getNotifIcon(type: string): string {
16
+ if (type === 'memo_assigned') return '📩'
17
+ if (type === 'memo_mention_all') return '📢'
18
+ if (type === 'reply_received') return '💬'
19
+ return '🔔'
20
+ }
21
+
22
+ function formatTimeAgo(ts: number): string {
23
+ const diff = Date.now() - ts
24
+ const mins = Math.floor(diff / 60_000)
25
+ if (mins < 1) return 'just now'
26
+ if (mins < 60) return `${mins}min ago`
27
+ const hours = Math.floor(mins / 60)
28
+ if (hours < 24) return `${hours}hr ago`
29
+ const days = Math.floor(hours / 24)
30
+ return `${days}d ago`
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div class="notification-bell">
36
+ <button class="bell-trigger" @click.stop="emit('toggle')">
37
+ <span class="bell-icon">&#128276;</span>
38
+ <span v-if="unreadCount > 0" class="bell-badge">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
39
+ </button>
40
+ <div class="notif-dropdown">
41
+ <div class="notif-header">
42
+ <span class="notif-header-title">Notifications</span>
43
+ <button v-if="unreadCount > 0" class="notif-mark-all" @click="emit('markAllRead')">Mark all read</button>
44
+ </div>
45
+ <div class="notif-list">
46
+ <div
47
+ v-for="n in notifications"
48
+ :key="n.id"
49
+ class="notif-item"
50
+ :class="{ unread: !n.isRead }"
51
+ @click="emit('click', n)"
52
+ >
53
+ <span class="notif-item-icon">{{ getNotifIcon(n.type) }}</span>
54
+ <div class="notif-item-content">
55
+ <div class="notif-item-title">{{ n.title }}</div>
56
+ <div v-if="n.body" class="notif-item-body">{{ n.body }}</div>
57
+ <div class="notif-item-meta">
58
+ <span class="notif-item-page">{{ n.pageId }}</span>
59
+ <span class="notif-item-time">{{ formatTimeAgo(n.createdAt) }}</span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ <div v-if="notifications.length === 0" class="notif-empty">No notifications</div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .notification-bell { position: relative; }
71
+ .bell-trigger {
72
+ position: relative; display: flex; align-items: center; justify-content: center;
73
+ width: 36px; height: 36px; border-radius: 6px; border: none; background: none;
74
+ cursor: pointer; transition: background 0.15s;
75
+ }
76
+ .bell-trigger:hover { background: var(--bg); }
77
+ .bell-icon { font-size: 18px; }
78
+ .bell-badge {
79
+ position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px;
80
+ padding: 0 4px; border-radius: 8px; background: #ef4444; color: #fff;
81
+ font-size: 10px; font-weight: 700; display: flex; align-items: center;
82
+ justify-content: center; line-height: 1; animation: pulse-notif 2s infinite;
83
+ }
84
+ @keyframes pulse-notif { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }
85
+ .notif-dropdown {
86
+ position: absolute; top: calc(100% + 4px); right: 0; width: 340px; max-height: 440px;
87
+ background: #fff; border: 1px solid var(--border); border-radius: 8px;
88
+ box-shadow: var(--shadow-md); z-index: 1000; display: flex; flex-direction: column; overflow: hidden;
89
+ }
90
+ .notif-header {
91
+ display: flex; align-items: center; justify-content: space-between;
92
+ padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
93
+ }
94
+ .notif-header-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
95
+ .notif-mark-all {
96
+ background: none; border: none; color: #3b82f6; font-size: 12px; font-weight: 500;
97
+ cursor: pointer; padding: 2px 6px; border-radius: 4px;
98
+ }
99
+ .notif-mark-all:hover { background: #eff6ff; }
100
+ .notif-list { flex: 1; overflow-y: auto; }
101
+ .notif-item {
102
+ display: flex; gap: 10px; padding: 10px 16px; cursor: pointer;
103
+ transition: background 0.1s; border-bottom: 1px solid #f1f5f9;
104
+ }
105
+ .notif-item:hover { background: #f8fafc; }
106
+ .notif-item.unread { background: #eff6ff; }
107
+ .notif-item.unread:hover { background: #dbeafe; }
108
+ .notif-item-icon { font-size: 16px; flex-shrink: 0; padding-top: 2px; }
109
+ .notif-item-content { flex: 1; min-width: 0; }
110
+ .notif-item-title { font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.3; }
111
+ .notif-item-body { font-size: 12px; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .notif-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: var(--text-muted); }
113
+ .notif-item-page { background: #f1f5f9; padding: 1px 6px; border-radius: 3px; font-size: 10px; }
114
+ .notif-empty { padding: 32px 16px; text-align: center; color: var(--text-muted); font-size: 13px; }
115
+ @media (max-width: 767px) { .notif-dropdown { width: 300px; right: -8px; } }
116
+ </style>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { ArrowUp, Minus, ArrowDown } from 'lucide-vue-next'
3
+
4
+ const props = defineProps<{ priority: string }>()
5
+
6
+ const config: Record<string, { icon: any; color: string; label: string }> = {
7
+ high: { icon: ArrowUp, color: '#ef4444', label: 'High' },
8
+ medium: { icon: Minus, color: '#f59e0b', label: 'Medium' },
9
+ low: { icon: ArrowDown, color: '#3b82f6', label: 'Low' },
10
+ }
11
+
12
+ const c = config[props.priority] || { icon: Minus, color: '#9ca3af', label: props.priority || '-' }
13
+ </script>
14
+
15
+ <template>
16
+ <span class="priority-badge" :style="{ color: c.color }" :title="c.label">
17
+ <component :is="c.icon" :size="14" />
18
+ </span>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .priority-badge { display: inline-flex; align-items: center; }
23
+ </style>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, onUnmounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet } from '@/api/client'
5
+
6
+ const props = defineProps<{ visible: boolean }>()
7
+ const emit = defineEmits<{ close: [] }>()
8
+ const router = useRouter()
9
+
10
+ const query = ref('')
11
+ const results = ref<any[]>([])
12
+ const loading = ref(false)
13
+ let debounceTimer: ReturnType<typeof setTimeout>
14
+
15
+ watch(query, (q) => {
16
+ clearTimeout(debounceTimer)
17
+ if (q.length < 2) { results.value = []; return }
18
+ debounceTimer = setTimeout(() => search(q), 300)
19
+ })
20
+
21
+ async function search(q: string) {
22
+ loading.value = true
23
+ const { data } = await apiGet(`/api/v2/search?q=${encodeURIComponent(q)}`)
24
+ if (data?.results) results.value = data.results as any[]
25
+ loading.value = false
26
+ }
27
+
28
+ function navigate(r: any) {
29
+ router.push(r.url)
30
+ emit('close')
31
+ }
32
+
33
+ function typeIcon(t: string) {
34
+ return { story: '📋', memo: '💬', doc: '📄', meeting: '🎙️' }[t] || '📌'
35
+ }
36
+
37
+ function typeLabel(t: string) {
38
+ return { story: 'Story', memo: 'Memo', doc: 'Document', meeting: 'Meeting' }[t] || t
39
+ }
40
+
41
+ const grouped = ref<Record<string, any[]>>({})
42
+ watch(results, (r) => {
43
+ const g: Record<string, any[]> = {}
44
+ for (const item of r) {
45
+ if (!g[item.type]) g[item.type] = []
46
+ g[item.type].push(item)
47
+ }
48
+ grouped.value = g
49
+ })
50
+
51
+ function onKeydown(e: KeyboardEvent) {
52
+ if (e.key === 'Escape') emit('close')
53
+ }
54
+
55
+ onMounted(() => window.addEventListener('keydown', onKeydown))
56
+ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
57
+ </script>
58
+
59
+ <template>
60
+ <Teleport to="body">
61
+ <div v-if="visible" class="search-overlay" @click.self="emit('close')">
62
+ <div class="search-modal">
63
+ <input
64
+ v-model="query"
65
+ class="search-input"
66
+ placeholder="Search stories, memos, documents... (Esc to close)"
67
+ autofocus
68
+ />
69
+ <div v-if="loading" class="search-loading">Searching...</div>
70
+ <div v-else-if="query.length >= 2 && !results.length" class="search-empty">No results</div>
71
+ <div v-else class="search-results">
72
+ <template v-for="(items, type) in grouped" :key="type">
73
+ <div class="search-group-title">{{ typeIcon(type) }} {{ typeLabel(type) }}</div>
74
+ <div
75
+ v-for="r in items"
76
+ :key="r.type + r.id"
77
+ class="search-item"
78
+ @click="navigate(r)"
79
+ >
80
+ <div class="search-item-title">{{ r.title || r.id }}</div>
81
+ <div v-if="r.preview" class="search-item-preview">{{ r.preview }}</div>
82
+ </div>
83
+ </template>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </Teleport>
88
+ </template>
89
+
90
+ <style scoped>
91
+ .search-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999; display: flex; justify-content: center; padding-top: 120px; }
92
+ .search-modal { background: #fff; border-radius: 12px; width: 560px; max-height: 480px; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.2); display: flex; flex-direction: column; }
93
+ .search-input { border: none; padding: 16px 20px; font-size: 16px; outline: none; border-bottom: 1px solid #e5e7eb; }
94
+ .search-results { overflow-y: auto; padding: 8px; flex: 1; }
95
+ .search-group-title { font-size: 12px; font-weight: 600; color: #9ca3af; padding: 8px 12px 4px; }
96
+ .search-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; }
97
+ .search-item:hover { background: #f3f4f6; }
98
+ .search-item-title { font-size: 14px; font-weight: 500; }
99
+ .search-item-preview { font-size: 12px; color: #6b7280; margin-top: 2px; }
100
+ .search-loading, .search-empty { padding: 24px; text-align: center; color: #9ca3af; font-size: 14px; }
101
+ @media (max-width: 640px) { .search-modal { width: 95%; margin: 0 auto; } }
102
+ </style>
@@ -0,0 +1,123 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import Suggestion from '@tiptap/suggestion'
3
+
4
+ interface SlashItem {
5
+ title: string
6
+ icon: string
7
+ command: (editor: any) => void
8
+ }
9
+
10
+ const items: SlashItem[] = [
11
+ { title: 'Heading 1', icon: 'H1', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
12
+ { title: 'Heading 2', icon: 'H2', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
13
+ { title: 'Heading 3', icon: 'H3', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
14
+ { title: 'Bullet List', icon: '•', command: (e) => e.chain().focus().toggleBulletList().run() },
15
+ { title: 'Numbered List', icon: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
16
+ { title: 'Code Block', icon: '<>', command: (e) => e.chain().focus().toggleCodeBlock().run() },
17
+ { title: 'Blockquote', icon: '"', command: (e) => e.chain().focus().toggleBlockquote().run() },
18
+ { title: 'Table', icon: '⊞', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
19
+ { title: 'Divider', icon: '—', command: (e) => e.chain().focus().setHorizontalRule().run() },
20
+ { title: 'Embed', icon: '🔗', command: (e) => {
21
+ const url = prompt('Enter the URL to embed:')
22
+ if (url) e.chain().focus().insertContent(`<p><a href="${url}" target="_blank">${url}</a></p>`).run()
23
+ }},
24
+ { title: 'Sub Page', icon: '📑', command: async (e) => {
25
+ const title = prompt('Sub page title:')
26
+ if (!title) return
27
+ const match = window.location.pathname.match(/\/docs\/([^/]+)/)
28
+ const parentId = match?.[1] || null
29
+ const slug = `sub-${Date.now()}`
30
+ try {
31
+ const { apiPut } = await import('@/composables/useTurso')
32
+ const { error } = await apiPut(`/api/v2/docs/${slug}`, { title, content: '', contentFormat: 'markdown', parentId })
33
+ if (error) { alert(`Failed to create sub page: ${error}`); return }
34
+ e.chain().focus().insertContent(`<p><a href="/docs/${slug}">${title}</a></p>`).run()
35
+ } catch (err) { alert(`Failed to create sub page: ${err}`) }
36
+ }},
37
+ ]
38
+
39
+ export const SlashCommand = Extension.create({
40
+ name: 'slashCommand',
41
+ addOptions() {
42
+ return {
43
+ suggestion: {
44
+ char: '/',
45
+ items: ({ query }: { query: string }) => items.filter(i => i.title.toLowerCase().includes(query.toLowerCase())),
46
+ render: () => {
47
+ let popup: HTMLElement | null = null
48
+ let selectedIndex = 0
49
+ let currentItems: SlashItem[] = []
50
+
51
+ function updatePopup() {
52
+ if (!popup) return
53
+ popup.innerHTML = currentItems.map((item, i) =>
54
+ `<div class="slash-item${i === selectedIndex ? ' slash-active' : ''}" data-index="${i}">
55
+ <span class="slash-icon">${item.icon}</span>
56
+ <span>${item.title}</span>
57
+ </div>`
58
+ ).join('')
59
+ }
60
+
61
+ return {
62
+ onStart(props: any) {
63
+ currentItems = props.items
64
+ selectedIndex = 0
65
+ popup = document.createElement('div')
66
+ popup.className = 'slash-menu'
67
+ popup.addEventListener('click', (e) => {
68
+ const target = (e.target as HTMLElement).closest('.slash-item')
69
+ if (target) {
70
+ const idx = Number(target.getAttribute('data-index'))
71
+ currentItems[idx]?.command(props.editor)
72
+ props.command({})
73
+ }
74
+ })
75
+ updatePopup()
76
+ const { view } = props.editor
77
+ const coords = view.coordsAtPos(props.range.from)
78
+ popup.style.position = 'fixed'
79
+ popup.style.left = `${coords.left}px`
80
+ popup.style.top = `${coords.bottom + 4}px`
81
+ document.body.appendChild(popup)
82
+ },
83
+ onUpdate(props: any) {
84
+ currentItems = props.items
85
+ selectedIndex = 0
86
+ updatePopup()
87
+ },
88
+ onKeyDown(props: any) {
89
+ if (props.event.key === 'ArrowDown') {
90
+ selectedIndex = (selectedIndex + 1) % currentItems.length
91
+ updatePopup()
92
+ return true
93
+ }
94
+ if (props.event.key === 'ArrowUp') {
95
+ selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length
96
+ updatePopup()
97
+ return true
98
+ }
99
+ if (props.event.key === 'Enter') {
100
+ currentItems[selectedIndex]?.command(props.editor)
101
+ props.command({})
102
+ return true
103
+ }
104
+ return false
105
+ },
106
+ onExit() {
107
+ popup?.remove()
108
+ popup = null
109
+ },
110
+ }
111
+ },
112
+ },
113
+ }
114
+ },
115
+ addProseMirrorPlugins() {
116
+ return [
117
+ Suggestion({
118
+ editor: this.editor,
119
+ ...this.options.suggestion,
120
+ }),
121
+ ]
122
+ },
123
+ })
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ type: 'loading' | 'empty' | 'error'
4
+ message?: string
5
+ ctaLabel?: string
6
+ ctaTo?: string
7
+ }>()
8
+
9
+ const emit = defineEmits<{ cta: [] }>()
10
+ </script>
11
+
12
+ <template>
13
+ <div class="state-display" :class="`state--${type}`">
14
+ <template v-if="type === 'loading'">
15
+ <div class="spinner" />
16
+ <p>{{ message || 'Loading...' }}</p>
17
+ </template>
18
+
19
+ <template v-if="type === 'empty'">
20
+ <div class="empty-icon">📭</div>
21
+ <p>{{ message || 'No data available' }}</p>
22
+ <router-link v-if="ctaTo" :to="ctaTo" class="cta-btn">{{ ctaLabel || 'Get started' }}</router-link>
23
+ <button v-else-if="ctaLabel" class="cta-btn" @click="emit('cta')">{{ ctaLabel }}</button>
24
+ </template>
25
+
26
+ <template v-if="type === 'error'">
27
+ <div class="error-icon">⚠️</div>
28
+ <p>{{ message || 'Something went wrong' }}</p>
29
+ <button class="cta-btn" @click="emit('cta')">{{ ctaLabel || 'Retry' }}</button>
30
+ </template>
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .state-display {
36
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
37
+ padding: 48px 24px; text-align: center; gap: 12px;
38
+ }
39
+ .state-display p { color: var(--text-secondary); font-size: 14px; }
40
+ .spinner {
41
+ width: 32px; height: 32px; border: 3px solid rgba(0,0,0,0.06);
42
+ border-top-color: var(--primary); border-radius: 50%;
43
+ animation: spin 0.8s linear infinite;
44
+ }
45
+ @keyframes spin { to { transform: rotate(360deg); } }
46
+ .empty-icon, .error-icon { font-size: 40px; }
47
+ .cta-btn {
48
+ margin-top: 8px; padding: 8px 20px; border-radius: 10px;
49
+ background: var(--primary); color: #fff; border: none;
50
+ font-size: 14px; font-weight: 500; cursor: pointer;
51
+ text-decoration: none; display: inline-block;
52
+ }
53
+ .cta-btn:hover { opacity: 0.9; }
54
+ </style>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { useRouter } from 'vue-router'
3
+
4
+ interface DocNode {
5
+ id: string; title: string; icon: string | null; is_folder: number
6
+ parent_id: string | null; sort_order: number; children: DocNode[]
7
+ }
8
+
9
+ const props = defineProps<{
10
+ node: DocNode
11
+ activeDocId?: string
12
+ expanded: Set<string>
13
+ depth?: number
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ toggle: [id: string]
18
+ dragstart: [e: DragEvent, id: string]
19
+ drop: [e: DragEvent, node: DocNode]
20
+ dropRoot: [e: DragEvent]
21
+ contextmenu: [e: MouseEvent, node: DocNode]
22
+ }>()
23
+
24
+ const router = useRouter()
25
+ const depth = props.depth ?? 0
26
+
27
+ function onDragStart(e: DragEvent) {
28
+ e.stopPropagation()
29
+ e.dataTransfer?.setData('doc-id', props.node.id)
30
+ emit('dragstart', e, props.node.id)
31
+ }
32
+
33
+ function navigate() {
34
+ router.push(`/docs/${props.node.id}`)
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <div>
40
+ <div
41
+ class="tree-item"
42
+ :class="{ active: activeDocId === node.id }"
43
+ :style="{ paddingLeft: (12 + depth * 16) + 'px' }"
44
+ draggable="true"
45
+ @dragstart.stop="onDragStart($event)"
46
+ @dragover.prevent
47
+ @drop.prevent.stop="emit('drop', $event, node)"
48
+ @contextmenu.prevent.stop="emit('contextmenu', $event, node)"
49
+ @click="navigate"
50
+ >
51
+ <span v-if="node.children.length" class="tree-arrow" @click.stop="emit('toggle', node.id)">
52
+ {{ expanded.has(node.id) ? '▼' : '▶' }}
53
+ </span>
54
+ <span class="tree-icon">{{ (node.icon && !node.icon.startsWith('Icon') && !node.icon.startsWith('<')) ? node.icon : '📄' }}</span>
55
+ <span class="tree-label">{{ node.title }}</span>
56
+ </div>
57
+ <div v-if="node.children.length && expanded.has(node.id)">
58
+ <TreeNode
59
+ v-for="child in node.children"
60
+ :key="child.id"
61
+ :node="child"
62
+ :active-doc-id="activeDocId"
63
+ :expanded="expanded"
64
+ :depth="depth + 1"
65
+ @toggle="emit('toggle', $event)"
66
+ @dragstart="(e: DragEvent, id: string) => emit('dragstart', e, id)"
67
+ @drop="(e: DragEvent, n: any) => emit('drop', e, n)"
68
+ @contextmenu="(e: MouseEvent, n: any) => emit('contextmenu', e, n)"
69
+ />
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped>
75
+ .tree-item { display: flex; align-items: center; gap: 8px; padding: 4px 12px; font-size: 13px; line-height: 28px; cursor: pointer; border-radius: 6px; margin: 1px 6px; transition: background 0.1s; position: relative; color: var(--text-sidebar, #d1d5db); }
76
+ .tree-item:hover { background: rgba(255,255,255,0.06); }
77
+ .tree-item.active { background: rgba(255,255,255,0.1); color: #fff; font-weight: 600; }
78
+ .tree-item.active::before { content: ''; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; background: var(--primary); border-radius: 2px; }
79
+ .tree-arrow { font-size: 10px; width: 14px; text-align: center; cursor: pointer; transition: transform 0.15s; }
80
+ .tree-icon { font-size: 15px; flex-shrink: 0; }
81
+ .tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
82
+ </style>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{ name: string; size?: number }>()
3
+
4
+ function hashColor(str: string): string {
5
+ let hash = 0
6
+ for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash)
7
+ const colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316']
8
+ return colors[Math.abs(hash) % colors.length]
9
+ }
10
+
11
+ const initial = props.name ? (props.name.length <= 3 ? props.name : props.name.slice(0, 3)) : '?'
12
+ const bg = hashColor(props.name || '')
13
+ const sz = props.size || 24
14
+ </script>
15
+
16
+ <template>
17
+ <span class="user-avatar" :style="{ width: sz + 'px', height: sz + 'px', background: bg }" :title="name">
18
+ {{ initial }}
19
+ </span>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .user-avatar { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; font-size: 9px; font-weight: 700; cursor: default; flex-shrink: 0; letter-spacing: -0.5px; }
24
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ data: Array<{ label: string; planned: number; actual: number }>
6
+ }>()
7
+
8
+ const maxValue = computed(() => {
9
+ const vals = props.data.flatMap(d => [d.planned, d.actual])
10
+ return Math.max(...vals, 1)
11
+ })
12
+
13
+ const chartWidth = 600
14
+ const chartHeight = 200
15
+ const padding = { top: 20, right: 20, bottom: 40, left: 50 }
16
+ const innerW = chartWidth - padding.left - padding.right
17
+ const innerH = chartHeight - padding.top - padding.bottom
18
+
19
+ function x(i: number): number {
20
+ return padding.left + (i / Math.max(props.data.length - 1, 1)) * innerW
21
+ }
22
+
23
+ function y(val: number): number {
24
+ return padding.top + innerH - (val / maxValue.value) * innerH
25
+ }
26
+
27
+ const plannedPath = computed(() => {
28
+ return props.data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(d.planned)}`).join(' ')
29
+ })
30
+
31
+ const actualPath = computed(() => {
32
+ return props.data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(d.actual)}`).join(' ')
33
+ })
34
+
35
+ const yTicks = computed(() => {
36
+ const max = maxValue.value
37
+ const step = Math.ceil(max / 4)
38
+ return [0, step, step * 2, step * 3, step * 4].filter(v => v <= max + step)
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <div class="velocity-chart">
44
+ <svg :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
45
+ <line v-for="tick in yTicks" :key="tick"
46
+ :x1="padding.left" :x2="chartWidth - padding.right"
47
+ :y1="y(tick)" :y2="y(tick)"
48
+ stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,4" />
49
+ <text v-for="tick in yTicks" :key="'t'+tick"
50
+ :x="padding.left - 8" :y="y(tick) + 4"
51
+ text-anchor="end" font-size="10" fill="var(--text-muted)">{{ tick }}</text>
52
+ <text v-for="(d, i) in data" :key="'x'+i"
53
+ :x="x(i)" :y="chartHeight - 8"
54
+ text-anchor="middle" font-size="10" fill="var(--text-muted)">{{ d.label }}</text>
55
+ <path :d="plannedPath" fill="none" stroke="rgba(148,163,184,0.5)" stroke-width="2" stroke-dasharray="6,4" />
56
+ <path :d="actualPath" fill="none" stroke="#3B82F6" stroke-width="2.5" />
57
+ <circle v-for="(d, i) in data" :key="'d'+i"
58
+ :cx="x(i)" :cy="y(d.actual)" r="4" fill="#3B82F6" stroke="#fff" stroke-width="2" />
59
+ <circle v-for="(d, i) in data" :key="'p'+i"
60
+ :cx="x(i)" :cy="y(d.planned)" r="3" fill="rgba(148,163,184,0.6)" />
61
+ </svg>
62
+ <div class="chart-legend">
63
+ <span class="legend-item"><span class="legend-line legend--actual"></span> Actual</span>
64
+ <span class="legend-item"><span class="legend-line legend--planned"></span> Planned</span>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .velocity-chart { width: 100%; }
71
+ .velocity-chart svg { width: 100%; height: auto; }
72
+ .chart-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
73
+ .legend-item { display: flex; align-items: center; gap: 4px; }
74
+ .legend-line { width: 16px; height: 2px; display: inline-block; }
75
+ .legend--actual { background: #3B82F6; }
76
+ .legend--planned { background: rgba(148,163,184,0.5); border-top: 2px dashed rgba(148,163,184,0.5); height: 0; }
77
+ </style>
@@ -12,8 +12,11 @@ export interface SprintConfig {
12
12
  label: string
13
13
  theme: string
14
14
  active: boolean
15
+ status?: 'planning' | 'active' | 'closed'
15
16
  startDate?: string | null
16
17
  endDate?: string | null
18
+ velocity?: number | null
19
+ teamSize?: number | null
17
20
  sortOrder: number
18
21
  }
19
22