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.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- 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()">•</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">✓</span>
|
|
62
|
+
<span v-else class="export-icon">📋</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">⇩</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>
|