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,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">🔔</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
|
|