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,117 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, onMounted } from 'vue'
4
+ import { useAuth } from '@/composables/useAuth'
5
+ import { loadNavData } from '@/composables/useNavStore'
6
+
7
+ const { isAuthenticated, authLoading, login, tryAutoLogin } = useAuth()
8
+
9
+ const tokenInput = ref('')
10
+ const loginError = ref(false)
11
+ const initializing = ref(true)
12
+
13
+ onMounted(async () => {
14
+ const ok = await tryAutoLogin()
15
+ if (ok) loadNavData()
16
+ initializing.value = false
17
+ })
18
+
19
+ async function handleLogin() {
20
+ loginError.value = false
21
+ const ok = await login(tokenInput.value.trim())
22
+ if (!ok) {
23
+ loginError.value = true
24
+ } else {
25
+ tokenInput.value = ''
26
+ loadNavData()
27
+ }
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <!-- Initializing -->
33
+ <div v-if="initializing" class="auth-init">
34
+ <div class="auth-spinner"></div>
35
+ </div>
36
+
37
+ <!-- Authenticated -->
38
+ <slot v-else-if="isAuthenticated" />
39
+
40
+ <!-- Login required -->
41
+ <div v-else class="auth-page">
42
+ <div class="auth-card">
43
+ <div class="auth-logo"><Icon name="sprint" :size="14" /></div>
44
+ <!-- TODO: Change title to your project name -->
45
+ <h1 class="auth-title">Project Spec</h1>
46
+ <p class="auth-desc">Team-only spec site.<br>Enter your auth token to continue.</p>
47
+
48
+ <div class="auth-form">
49
+ <input
50
+ v-model="tokenInput"
51
+ type="text"
52
+ class="auth-input"
53
+ placeholder="Auth token"
54
+ autocomplete="off"
55
+ @keydown.enter="handleLogin"
56
+ />
57
+ <button
58
+ class="auth-btn"
59
+ @click="handleLogin"
60
+ :disabled="authLoading || !tokenInput.trim()"
61
+ >
62
+ {{ authLoading ? 'Verifying...' : 'Enter' }}
63
+ </button>
64
+ </div>
65
+
66
+ <p v-if="loginError" class="auth-error">Invalid token</p>
67
+ <p class="auth-hint">Contact your team admin for a token</p>
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .auth-init {
74
+ height: 100vh; display: flex; align-items: center; justify-content: center;
75
+ background: #f8fafc;
76
+ }
77
+ .auth-spinner {
78
+ width: 32px; height: 32px; border: 3px solid #e2e8f0;
79
+ border-top-color: #3b82f6; border-radius: 50%;
80
+ animation: spin 0.8s linear infinite;
81
+ }
82
+ @keyframes spin { to { transform: rotate(360deg); } }
83
+
84
+ .auth-page {
85
+ height: 100vh; display: flex; align-items: center; justify-content: center;
86
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
87
+ }
88
+
89
+ .auth-card {
90
+ background: #fff; border-radius: 16px; padding: 48px 40px;
91
+ box-shadow: 0 4px 24px rgba(0,0,0,0.08);
92
+ text-align: center; width: 380px; max-width: 90vw;
93
+ }
94
+
95
+ .auth-logo { font-size: 48px; margin-bottom: 12px; }
96
+ .auth-title { font-size: 22px; font-weight: 700; color: #1e293b; margin-bottom: 8px; }
97
+ .auth-desc { font-size: 14px; color: #64748b; line-height: 1.6; margin-bottom: 28px; }
98
+
99
+ .auth-form { display: flex; gap: 8px; }
100
+ .auth-input {
101
+ flex: 1; padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 10px;
102
+ font-size: 14px; font-family: 'Roboto Mono', monospace; color: #1e293b;
103
+ }
104
+ .auth-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); }
105
+ .auth-input::placeholder { color: #94a3b8; }
106
+
107
+ .auth-btn {
108
+ padding: 12px 20px; background: #1e293b; color: #fff; border: none; border-radius: 10px;
109
+ font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap;
110
+ transition: background 0.15s;
111
+ }
112
+ .auth-btn:hover { background: #334155; }
113
+ .auth-btn:disabled { background: #cbd5e1; cursor: not-allowed; }
114
+
115
+ .auth-error { color: #ef4444; font-size: 13px; margin-top: 12px; }
116
+ .auth-hint { color: #94a3b8; font-size: 12px; margin-top: 16px; }
117
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed, watch } from 'vue'
3
+ import { apiGet } from '@/composables/useTurso'
4
+
5
+ const props = defineProps<{ sprintId: string }>()
6
+
7
+ const data = ref<{ totalSP: number; dailyDone: any[]; startDate: string; endDate: string } | null>(null)
8
+
9
+ async function loadBurndown() {
10
+ const { data: d } = await apiGet(`/api/v2/pm/sprints/${props.sprintId}/burndown`)
11
+ data.value = d as any
12
+ }
13
+
14
+ onMounted(loadBurndown)
15
+ watch(() => props.sprintId, loadBurndown)
16
+
17
+ const chartData = computed(() => {
18
+ if (!data.value) return { points: [], idealPoints: [], dates: [] }
19
+ const { totalSP, dailyDone, startDate, endDate } = data.value
20
+ if (!startDate || !endDate || !totalSP) return { points: [], idealPoints: [], dates: [] }
21
+
22
+ // Date array
23
+ const dates: string[] = []
24
+ let d = new Date(startDate)
25
+ const end = new Date(endDate)
26
+ while (d <= end) { dates.push(d.toISOString().split('T')[0]); d.setDate(d.getDate() + 1) }
27
+
28
+ // Daily done SP map
29
+ const doneMap: Record<string, number> = {}
30
+ for (const r of dailyDone) doneMap[r.date] = r.sp
31
+
32
+ // Remaining
33
+ let cumDone = 0
34
+ const points = dates.map(dt => { cumDone += doneMap[dt] || 0; return totalSP - cumDone })
35
+
36
+ // Ideal
37
+ const idealPoints = dates.map((_, i) => Math.round(totalSP * (1 - i / (dates.length - 1))))
38
+
39
+ return { points, idealPoints, dates }
40
+ })
41
+
42
+ const svgW = 400
43
+ const svgH = 200
44
+ const pad = 30
45
+
46
+ function x(i: number, total: number) { return pad + (i / Math.max(total - 1, 1)) * (svgW - pad * 2) }
47
+ function y(val: number, max: number) { return pad + (1 - val / Math.max(max, 1)) * (svgH - pad * 2) }
48
+
49
+ function polyline(points: number[], max: number): string {
50
+ return points.map((p, i) => `${x(i, points.length)},${y(p, max)}`).join(' ')
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <div class="burndown-chart">
56
+ <h3>Burndown Chart</h3>
57
+ <svg v-if="chartData.points.length" :viewBox="`0 0 ${svgW} ${svgH}`" class="chart-svg">
58
+ <!-- Ideal line -->
59
+ <polyline :points="polyline(chartData.idealPoints, data?.totalSP || 1)" fill="none" stroke="#d1d5db" stroke-width="1.5" stroke-dasharray="4" />
60
+ <!-- Actual line -->
61
+ <polyline :points="polyline(chartData.points, data?.totalSP || 1)" fill="none" stroke="var(--primary)" stroke-width="2" />
62
+ <!-- Axes -->
63
+ <line :x1="pad" :y1="svgH - pad" :x2="svgW - pad" :y2="svgH - pad" stroke="#e5e7eb" />
64
+ <line :x1="pad" :y1="pad" :x2="pad" :y2="svgH - pad" stroke="#e5e7eb" />
65
+ <!-- Labels -->
66
+ <text :x="pad" :y="pad - 5" font-size="10" fill="var(--text-muted)">{{ data?.totalSP }}SP</text>
67
+ <text :x="svgW - pad" :y="svgH - pad + 15" font-size="9" fill="var(--text-muted)" text-anchor="end">{{ chartData.dates[chartData.dates.length - 1] }}</text>
68
+ </svg>
69
+ <div v-else class="chart-empty">No data</div>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .burndown-chart { background: var(--bg-card); border-radius: var(--radius-lg); padding: 16px; }
75
+ .burndown-chart h3 { font-size: 14px; font-weight: 700; margin: 0 0 12px; }
76
+ .chart-svg { width: 100%; max-width: 500px; }
77
+ .chart-empty { color: var(--text-muted); font-size: 13px; text-align: center; padding: 24px; }
78
+ </style>
@@ -0,0 +1,137 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { apiGet, apiPost, apiPatch, apiDelete } from '@/composables/useTurso'
4
+
5
+ interface Comment {
6
+ id: number; doc_id: string; parent_id: number | null; author: string; content: string; created_at: string; updated_at: string
7
+ }
8
+
9
+ const props = defineProps<{ docId: string; currentUser: string }>()
10
+
11
+ const comments = ref<Comment[]>([])
12
+ const newComment = ref('')
13
+ const replyTo = ref<number | null>(null)
14
+ const editingId = ref<number | null>(null)
15
+ const editingText = ref('')
16
+
17
+ async function load() {
18
+ const { data } = await apiGet<{ comments: Comment[] }>(`/api/v2/docs/${props.docId}/comments`)
19
+ comments.value = data?.comments || []
20
+ }
21
+
22
+ const rootComments = () => comments.value.filter(c => !c.parent_id)
23
+ const replies = (parentId: number) => comments.value.filter(c => c.parent_id === parentId)
24
+
25
+ async function submit() {
26
+ const trimmed = newComment.value.trim()
27
+ if (!trimmed) return
28
+ await apiPost(`/api/v2/docs/${props.docId}/comments`, { content: trimmed, parentId: replyTo.value })
29
+ // @mention notification for doc comments
30
+ if (trimmed.includes('@')) {
31
+ apiPost('/api/v2/notifications/mention', {
32
+ content: trimmed,
33
+ sourceType: 'doc',
34
+ sourceId: props.docId,
35
+ pageId: `/docs/${props.docId}`,
36
+ actor: props.currentUser,
37
+ }).catch(() => {})
38
+ }
39
+ newComment.value = ''; replyTo.value = null
40
+ await load()
41
+ }
42
+
43
+ async function startEdit(c: Comment) { editingId.value = c.id; editingText.value = c.content }
44
+
45
+ async function saveEdit() {
46
+ if (!editingId.value) return
47
+ await apiPatch(`/api/v2/docs/comments/${editingId.value}`, { content: editingText.value })
48
+ editingId.value = null; await load()
49
+ }
50
+
51
+ async function remove(id: number) {
52
+ if (!confirm('Delete this comment?')) return
53
+ await apiDelete(`/api/v2/docs/comments/${id}`)
54
+ await load()
55
+ }
56
+
57
+ onMounted(load)
58
+ </script>
59
+
60
+ <template>
61
+ <div class="doc-comments">
62
+ <h3 class="comments-title">Comments ({{ comments.length }})</h3>
63
+
64
+ <div v-for="c in rootComments()" :key="c.id" class="comment-thread">
65
+ <div class="comment-item">
66
+ <div class="comment-header">
67
+ <span class="comment-author">{{ c.author }}</span>
68
+ <span class="comment-time">{{ c.created_at }}</span>
69
+ </div>
70
+ <div v-if="editingId === c.id" class="comment-edit">
71
+ <textarea v-model="editingText" class="comment-textarea" rows="2" />
72
+ <button class="btn btn--xs btn--primary" @click="saveEdit">Save</button>
73
+ <button class="btn btn--xs" @click="editingId = null">Cancel</button>
74
+ </div>
75
+ <div v-else class="comment-body">{{ c.content }}</div>
76
+ <div class="comment-actions">
77
+ <button class="comment-action" @click="replyTo = c.id">Reply</button>
78
+ <template v-if="c.author === currentUser">
79
+ <button class="comment-action" @click="startEdit(c)">Edit</button>
80
+ <button class="comment-action danger" @click="remove(c.id)">Delete</button>
81
+ </template>
82
+ </div>
83
+
84
+ <!-- Replies -->
85
+ <div v-for="r in replies(c.id)" :key="r.id" class="comment-reply">
86
+ <div class="comment-header">
87
+ <span class="comment-author">{{ r.author }}</span>
88
+ <span class="comment-time">{{ r.created_at }}</span>
89
+ </div>
90
+ <div v-if="editingId === r.id" class="comment-edit">
91
+ <textarea v-model="editingText" class="comment-textarea" rows="2" />
92
+ <button class="btn btn--xs btn--primary" @click="saveEdit">Save</button>
93
+ <button class="btn btn--xs" @click="editingId = null">Cancel</button>
94
+ </div>
95
+ <div v-else class="comment-body">{{ r.content }}</div>
96
+ <div v-if="r.author === currentUser" class="comment-actions">
97
+ <button class="comment-action" @click="startEdit(r)">Edit</button>
98
+ <button class="comment-action danger" @click="remove(r.id)">Delete</button>
99
+ </div>
100
+ </div>
101
+
102
+ <!-- Reply input -->
103
+ <div v-if="replyTo === c.id" class="reply-form">
104
+ <textarea v-model="newComment" class="comment-textarea" rows="2" placeholder="Reply..." />
105
+ <button class="btn btn--xs btn--primary" @click="submit">Submit</button>
106
+ <button class="btn btn--xs" @click="replyTo = null">Cancel</button>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <!-- New comment -->
112
+ <div v-if="!replyTo" class="new-comment">
113
+ <textarea v-model="newComment" class="comment-textarea" rows="2" placeholder="Write a comment..." />
114
+ <button class="btn btn--sm btn--primary" @click="submit" :disabled="!newComment.trim()">Submit</button>
115
+ </div>
116
+ </div>
117
+ </template>
118
+
119
+ <style scoped>
120
+ .doc-comments { margin-top: 24px; border-top: 1px solid var(--border, #e5e7eb); padding-top: 16px; }
121
+ .comments-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
122
+ .comment-thread { margin-bottom: 12px; }
123
+ .comment-item { padding: 8px 0; }
124
+ .comment-header { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
125
+ .comment-author { font-size: 13px; font-weight: 600; }
126
+ .comment-time { font-size: 11px; color: #9ca3af; }
127
+ .comment-body { font-size: 13px; line-height: 1.6; color: #374151; }
128
+ .comment-actions { display: flex; gap: 8px; margin-top: 4px; }
129
+ .comment-action { border: none; background: none; font-size: 11px; color: #6b7280; cursor: pointer; padding: 0; }
130
+ .comment-action:hover { color: #3b82f6; }
131
+ .comment-action.danger:hover { color: #ef4444; }
132
+ .comment-reply { margin-left: 24px; padding: 6px 0; border-left: 2px solid #e5e7eb; padding-left: 12px; }
133
+ .comment-textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 6px; padding: 8px; font-size: 13px; resize: vertical; box-sizing: border-box; }
134
+ .comment-edit { display: flex; flex-direction: column; gap: 4px; }
135
+ .reply-form { margin-top: 8px; margin-left: 24px; display: flex; flex-direction: column; gap: 4px; }
136
+ .new-comment { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
137
+ </style>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, watch } from 'vue'
3
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import Link from '@tiptap/extension-link'
6
+ import Image from '@tiptap/extension-image'
7
+ import { Table } from '@tiptap/extension-table'
8
+ import TableRow from '@tiptap/extension-table-row'
9
+ import TableCell from '@tiptap/extension-table-cell'
10
+ import TableHeader from '@tiptap/extension-table-header'
11
+ import Placeholder from '@tiptap/extension-placeholder'
12
+ import { SlashCommand } from './SlashCommand'
13
+
14
+ const props = defineProps<{ modelValue: string; editable?: boolean }>()
15
+ const emit = defineEmits<{ 'update:modelValue': [value: string]; 'focus': []; 'blur': [] }>()
16
+
17
+ const editor = useEditor({
18
+ content: props.modelValue,
19
+ editable: props.editable !== false,
20
+ extensions: [
21
+ StarterKit,
22
+ Link.configure({ openOnClick: false }),
23
+ Image,
24
+ Table.configure({ resizable: true }),
25
+ TableRow, TableCell, TableHeader,
26
+ Placeholder.configure({ placeholder: 'Start writing... Type / to open the block menu.' }),
27
+ SlashCommand,
28
+ ],
29
+ onUpdate: ({ editor: e }) => {
30
+ emit('update:modelValue', e.getHTML())
31
+ },
32
+ onFocus: () => emit('focus'),
33
+ onBlur: () => emit('blur'),
34
+ })
35
+
36
+ watch(() => props.modelValue, (val) => {
37
+ if (editor.value && editor.value.getHTML() !== val) {
38
+ editor.value.commands.setContent(val, false)
39
+ }
40
+ })
41
+
42
+ onBeforeUnmount(() => { editor.value?.destroy() })
43
+ </script>
44
+
45
+ <template>
46
+ <div v-if="editor" class="doc-editor">
47
+ <!-- Toolbar -->
48
+ <div v-if="editable !== false" class="editor-toolbar">
49
+ <button :class="{ active: editor.isActive('bold') }" @click="editor.chain().focus().toggleBold().run()"><b>B</b></button>
50
+ <button :class="{ active: editor.isActive('italic') }" @click="editor.chain().focus().toggleItalic().run()"><i>I</i></button>
51
+ <span class="toolbar-sep" />
52
+ <button :class="{ active: editor.isActive('heading', { level: 1 }) }" @click="editor.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
53
+ <button :class="{ active: editor.isActive('heading', { level: 2 }) }" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
54
+ <button :class="{ active: editor.isActive('heading', { level: 3 }) }" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()">H3</button>
55
+ <span class="toolbar-sep" />
56
+ <button :class="{ active: editor.isActive('bulletList') }" @click="editor.chain().focus().toggleBulletList().run()">&#8226;</button>
57
+ <button :class="{ active: editor.isActive('orderedList') }" @click="editor.chain().focus().toggleOrderedList().run()">1.</button>
58
+ <button :class="{ active: editor.isActive('codeBlock') }" @click="editor.chain().focus().toggleCodeBlock().run()">Code</button>
59
+ <button :class="{ active: editor.isActive('blockquote') }" @click="editor.chain().focus().toggleBlockquote().run()">Quote</button>
60
+ <span class="toolbar-sep" />
61
+ <button @click="addLink">Link</button>
62
+ <button @click="addImage">Image</button>
63
+ <button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()">Table</button>
64
+ </div>
65
+ <EditorContent :editor="editor" class="editor-content" />
66
+ </div>
67
+ </template>
68
+
69
+ <script lang="ts">
70
+ export default {
71
+ methods: {
72
+ addLink() {
73
+ const url = prompt('Enter URL:')
74
+ if (url) (this as any).editor?.chain().focus().setLink({ href: url }).run()
75
+ },
76
+ addImage() {
77
+ const url = prompt('Enter image URL:')
78
+ if (url) (this as any).editor?.chain().focus().setImage({ src: url }).run()
79
+ },
80
+ },
81
+ }
82
+ </script>
83
+
84
+ <style scoped>
85
+ .doc-editor { border: none; overflow: hidden; }
86
+ .editor-toolbar { display: flex; gap: 2px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; margin-bottom: 16px; flex-wrap: wrap; position: sticky; top: 0; background: #fff; z-index: 10; }
87
+ .editor-toolbar button { border: none; background: none; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; color: #374151; }
88
+ .editor-toolbar button:hover { background: #e5e7eb; }
89
+ .editor-toolbar button.active { background: #3b82f6; color: #fff; }
90
+ .toolbar-sep { width: 1px; background: #d1d5db; margin: 0 4px; }
91
+ .editor-content { min-height: 300px; padding: 16px; }
92
+ .editor-content :deep(.ProseMirror) { outline: none; min-height: 280px; }
93
+ .editor-content :deep(.ProseMirror) { font-size: 15px; line-height: 1.8; color: #1a1a1a; }
94
+ .editor-content :deep(.ProseMirror p) { margin: 0.5em 0; }
95
+ .editor-content :deep(.ProseMirror h1) { font-size: 24px; font-weight: 700; margin: 1em 0 0.5em; }
96
+ .editor-content :deep(.ProseMirror h2) { font-size: 20px; font-weight: 600; margin: 0.8em 0 0.4em; }
97
+ .editor-content :deep(.ProseMirror h3) { font-size: 16px; font-weight: 600; margin: 0.6em 0 0.3em; }
98
+ .editor-content :deep(.ProseMirror code) { background: #f1f5f9; color: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'SF Mono', 'Fira Code', monospace; }
99
+ .editor-content :deep(.ProseMirror pre) { background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.6; }
100
+ .editor-content :deep(.ProseMirror pre code) { background: none; color: inherit; padding: 0; border-radius: 0; font-size: inherit; }
101
+ .editor-content :deep(.ProseMirror pre::selection), .editor-content :deep(.ProseMirror pre *::selection) { background: rgba(99, 130, 191, 0.3); }
102
+ .editor-content :deep(.ProseMirror .ProseMirror-selectednode) { outline: 2px solid #3b82f6; }
103
+ .editor-content :deep(.ProseMirror blockquote) { border-left: 3px solid #3b82f6; padding-left: 16px; color: #475569; background: #f8fafc; margin: 12px 0; padding: 12px 16px; border-radius: 0 6px 6px 0; }
104
+ .editor-content :deep(.ProseMirror ul) { padding-left: 20px; }
105
+ .editor-content :deep(.ProseMirror ol) { padding-left: 20px; }
106
+ .editor-content :deep(.ProseMirror img) { max-width: 100%; border-radius: 8px; }
107
+ .editor-content :deep(.ProseMirror table) { border-collapse: collapse; width: 100%; }
108
+ .editor-content :deep(.ProseMirror td), .editor-content :deep(.ProseMirror th) { border: 1px solid #d1d5db; padding: 6px 8px; }
109
+ .editor-content :deep(.ProseMirror th) { background: #f9fafb; font-weight: 600; }
110
+ .editor-content :deep(.ProseMirror .is-empty::before) { content: attr(data-placeholder); color: #9ca3af; pointer-events: none; float: left; height: 0; }
111
+ </style>
112
+
113
+ <style>
114
+ .slash-menu { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 4px; min-width: 180px; z-index: 9999; }
115
+ .slash-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 13px; }
116
+ .slash-item:hover, .slash-active { background: #eff6ff; }
117
+ .slash-icon { width: 20px; text-align: center; font-weight: 700; font-size: 12px; color: #6b7280; }
118
+ </style>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ /** Raw markdown string (if available). If not provided, falls back to domRef text extraction */
6
+ rawMarkdown?: string
7
+ /** DOM element to extract text from when rawMarkdown is not available */
8
+ domRef?: HTMLElement | null
9
+ /** File name for download (without .md extension) */
10
+ fileName?: string
11
+ }>()
12
+
13
+ const copied = ref(false)
14
+
15
+ function getContent(): string {
16
+ if (props.rawMarkdown) return props.rawMarkdown
17
+ if (props.domRef) return props.domRef.innerText
18
+ return ''
19
+ }
20
+
21
+ async function handleCopy() {
22
+ const content = getContent()
23
+ if (!content) return
24
+ try {
25
+ await navigator.clipboard.writeText(content)
26
+ copied.value = true
27
+ setTimeout(() => { copied.value = false }, 2000)
28
+ } catch {
29
+ // Fallback for older browsers
30
+ const ta = document.createElement('textarea')
31
+ ta.value = content
32
+ ta.style.position = 'fixed'
33
+ ta.style.left = '-9999px'
34
+ document.body.appendChild(ta)
35
+ ta.select()
36
+ document.execCommand('copy')
37
+ document.body.removeChild(ta)
38
+ copied.value = true
39
+ setTimeout(() => { copied.value = false }, 2000)
40
+ }
41
+ }
42
+
43
+ function handleDownload() {
44
+ const content = getContent()
45
+ if (!content) return
46
+ const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
47
+ const url = URL.createObjectURL(blob)
48
+ const a = document.createElement('a')
49
+ a.href = url
50
+ a.download = (props.fileName || 'document') + '.md'
51
+ document.body.appendChild(a)
52
+ a.click()
53
+ document.body.removeChild(a)
54
+ URL.revokeObjectURL(url)
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <div class="doc-export-bar">
60
+ <button class="export-btn" @click="handleCopy" :title="copied ? 'Copied!' : 'Copy to clipboard'">
61
+ <span v-if="copied" class="export-icon">&#10003;</span>
62
+ <span v-else class="export-icon">&#128203;</span>
63
+ <span class="export-label">{{ copied ? 'Copied' : 'Copy' }}</span>
64
+ </button>
65
+ <button class="export-btn" @click="handleDownload" title="Download .md file">
66
+ <span class="export-icon">&#8681;</span>
67
+ <span class="export-label">.md</span>
68
+ </button>
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .doc-export-bar {
74
+ display: inline-flex;
75
+ gap: 4px;
76
+ align-items: center;
77
+ }
78
+
79
+ .export-btn {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 4px;
83
+ padding: 4px 10px;
84
+ font-size: 12px;
85
+ font-family: var(--font-sans, 'Inter', sans-serif);
86
+ color: var(--text-muted, #94a3b8);
87
+ background: var(--bg, #f8fafc);
88
+ border: 1px solid var(--border, #e2e8f0);
89
+ border-radius: 6px;
90
+ cursor: pointer;
91
+ transition: all 0.15s;
92
+ white-space: nowrap;
93
+ }
94
+
95
+ .export-btn:hover {
96
+ color: var(--text-primary, #1e293b);
97
+ border-color: var(--text-muted, #94a3b8);
98
+ background: #fff;
99
+ }
100
+
101
+ .export-icon {
102
+ font-size: 13px;
103
+ line-height: 1;
104
+ }
105
+
106
+ .export-label {
107
+ font-size: 11px;
108
+ font-weight: 500;
109
+ }
110
+ </style>