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