@thxgg/steward 0.1.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { marked } from 'marked'
|
|
3
|
+
import { codeToHtml } from 'shiki'
|
|
4
|
+
import DOMPurify from 'dompurify'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
content: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const renderedHtml = ref('')
|
|
11
|
+
const isLoading = ref(true)
|
|
12
|
+
|
|
13
|
+
// Configure DOMPurify to allow shiki's style attributes
|
|
14
|
+
const purifyConfig = {
|
|
15
|
+
ADD_TAGS: ['style'],
|
|
16
|
+
ADD_ATTR: ['style', 'class', 'target', 'rel']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Configure marked to open links in new tab
|
|
20
|
+
const renderer = new marked.Renderer()
|
|
21
|
+
renderer.link = ({ href, title, text }) => {
|
|
22
|
+
const titleAttr = title ? ` title="${title}"` : ''
|
|
23
|
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Custom code block renderer with shiki
|
|
27
|
+
async function highlightCode(code: string, lang: string): Promise<string> {
|
|
28
|
+
try {
|
|
29
|
+
return await codeToHtml(code, {
|
|
30
|
+
lang: lang || 'text',
|
|
31
|
+
themes: {
|
|
32
|
+
light: 'github-light',
|
|
33
|
+
dark: 'github-dark'
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
} catch {
|
|
37
|
+
// Fallback for unsupported languages
|
|
38
|
+
return `<pre><code class="language-${lang}">${escapeHtml(code)}</code></pre>`
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeHtml(text: string): string {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Extract code blocks and process them
|
|
52
|
+
async function renderMarkdown(content: string): Promise<string> {
|
|
53
|
+
// First pass: extract code blocks and replace with placeholders
|
|
54
|
+
// Use format that won't be interpreted as markdown formatting
|
|
55
|
+
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g
|
|
56
|
+
const placeholders: { placeholder: string; lang: string; code: string }[] = []
|
|
57
|
+
let placeholderIndex = 0
|
|
58
|
+
|
|
59
|
+
const contentWithPlaceholders = content.replace(codeBlockRegex, (_, lang, code) => {
|
|
60
|
+
const placeholder = `CODEBLOCK${placeholderIndex++}PLACEHOLDER`
|
|
61
|
+
placeholders.push({ placeholder, lang: lang || 'text', code: code.trim() })
|
|
62
|
+
return placeholder
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Parse markdown without code blocks
|
|
66
|
+
marked.setOptions({
|
|
67
|
+
renderer,
|
|
68
|
+
gfm: true,
|
|
69
|
+
breaks: false
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
let html = await marked.parse(contentWithPlaceholders)
|
|
73
|
+
|
|
74
|
+
// Highlight code blocks in parallel
|
|
75
|
+
const highlightPromises = placeholders.map(async ({ placeholder, lang, code }) => {
|
|
76
|
+
const highlighted = await highlightCode(code, lang)
|
|
77
|
+
return { placeholder, highlighted }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const results = await Promise.all(highlightPromises)
|
|
81
|
+
|
|
82
|
+
// Replace placeholders with highlighted code
|
|
83
|
+
for (const { placeholder, highlighted } of results) {
|
|
84
|
+
html = html.replace(`<p>${placeholder}</p>`, highlighted)
|
|
85
|
+
html = html.replace(placeholder, highlighted)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sanitize the final HTML to prevent XSS
|
|
89
|
+
return DOMPurify.sanitize(html, purifyConfig)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Render on mount and when content changes
|
|
93
|
+
watch(() => props.content, async (newContent) => {
|
|
94
|
+
if (newContent) {
|
|
95
|
+
isLoading.value = true
|
|
96
|
+
try {
|
|
97
|
+
renderedHtml.value = await renderMarkdown(newContent)
|
|
98
|
+
} finally {
|
|
99
|
+
isLoading.value = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}, { immediate: true })
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<div class="prd-viewer">
|
|
107
|
+
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
108
|
+
<div class="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
109
|
+
</div>
|
|
110
|
+
<div
|
|
111
|
+
v-else
|
|
112
|
+
class="prose prose-sm dark:prose-invert max-w-none"
|
|
113
|
+
v-html="renderedHtml"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<style>
|
|
119
|
+
/* Prose styling for markdown content */
|
|
120
|
+
.prd-viewer .prose {
|
|
121
|
+
--tw-prose-body: hsl(var(--foreground));
|
|
122
|
+
--tw-prose-headings: hsl(var(--foreground));
|
|
123
|
+
--tw-prose-links: hsl(var(--primary));
|
|
124
|
+
--tw-prose-code: hsl(var(--foreground));
|
|
125
|
+
--tw-prose-pre-bg: hsl(var(--muted));
|
|
126
|
+
--tw-prose-quotes: hsl(var(--muted-foreground));
|
|
127
|
+
--tw-prose-hr: hsl(var(--border));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.prd-viewer .prose h1,
|
|
131
|
+
.prd-viewer .prose h2,
|
|
132
|
+
.prd-viewer .prose h3,
|
|
133
|
+
.prd-viewer .prose h4 {
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
margin-top: 1.5em;
|
|
136
|
+
margin-bottom: 0.5em;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.prd-viewer .prose h1 {
|
|
140
|
+
font-size: 1.875rem;
|
|
141
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
142
|
+
padding-bottom: 0.5rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.prd-viewer .prose h2 {
|
|
146
|
+
font-size: 1.5rem;
|
|
147
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
148
|
+
padding-bottom: 0.25rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.prd-viewer .prose h3 {
|
|
152
|
+
font-size: 1.25rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.prd-viewer .prose p {
|
|
156
|
+
margin-top: 0.75em;
|
|
157
|
+
margin-bottom: 0.75em;
|
|
158
|
+
line-height: 1.7;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.prd-viewer .prose a {
|
|
162
|
+
color: hsl(var(--primary));
|
|
163
|
+
text-decoration: underline;
|
|
164
|
+
text-underline-offset: 2px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.prd-viewer .prose a:hover {
|
|
168
|
+
opacity: 0.8;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.prd-viewer .prose code:not(pre code) {
|
|
172
|
+
background: hsl(var(--muted));
|
|
173
|
+
padding: 0.125rem 0.375rem;
|
|
174
|
+
border-radius: 0.25rem;
|
|
175
|
+
font-size: 0.875em;
|
|
176
|
+
font-weight: 500;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.prd-viewer .prose pre {
|
|
180
|
+
background: hsl(var(--muted));
|
|
181
|
+
border-radius: 0.5rem;
|
|
182
|
+
padding: 1rem;
|
|
183
|
+
overflow-x: auto;
|
|
184
|
+
margin: 1rem 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Shiki code blocks */
|
|
188
|
+
.prd-viewer .prose pre.shiki {
|
|
189
|
+
background-color: hsl(var(--muted)) !important;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.prd-viewer .prose .shiki code {
|
|
193
|
+
background: transparent;
|
|
194
|
+
padding: 0;
|
|
195
|
+
font-size: 0.875rem;
|
|
196
|
+
line-height: 1.5;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Dark mode shiki */
|
|
200
|
+
.dark .prd-viewer .prose pre.shiki,
|
|
201
|
+
.dark .prd-viewer .prose .shiki {
|
|
202
|
+
background-color: hsl(var(--muted)) !important;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.dark .prd-viewer .prose .shiki.github-light {
|
|
206
|
+
display: none !important;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.light .prd-viewer .prose .shiki.github-dark,
|
|
210
|
+
:not(.dark) .prd-viewer .prose .shiki.github-dark {
|
|
211
|
+
display: none !important;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Tables */
|
|
215
|
+
.prd-viewer .prose table {
|
|
216
|
+
width: 100%;
|
|
217
|
+
border-collapse: collapse;
|
|
218
|
+
margin: 1rem 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.prd-viewer .prose th,
|
|
222
|
+
.prd-viewer .prose td {
|
|
223
|
+
border: 1px solid hsl(var(--border));
|
|
224
|
+
padding: 0.5rem 0.75rem;
|
|
225
|
+
text-align: left;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.prd-viewer .prose th {
|
|
229
|
+
background: hsl(var(--muted));
|
|
230
|
+
font-weight: 600;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.prd-viewer .prose tr:nth-child(even) {
|
|
234
|
+
background: hsl(var(--muted) / 0.3);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* Lists */
|
|
238
|
+
.prd-viewer .prose ul,
|
|
239
|
+
.prd-viewer .prose ol {
|
|
240
|
+
padding-left: 1.5rem;
|
|
241
|
+
margin: 0.75em 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.prd-viewer .prose li {
|
|
245
|
+
margin: 0.25em 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.prd-viewer .prose ul {
|
|
249
|
+
list-style-type: disc;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.prd-viewer .prose ol {
|
|
253
|
+
list-style-type: decimal;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Checkboxes (task lists) */
|
|
257
|
+
.prd-viewer .prose input[type="checkbox"] {
|
|
258
|
+
margin-right: 0.5rem;
|
|
259
|
+
pointer-events: none;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* Blockquotes */
|
|
263
|
+
.prd-viewer .prose blockquote {
|
|
264
|
+
border-left: 4px solid hsl(var(--border));
|
|
265
|
+
padding-left: 1rem;
|
|
266
|
+
margin: 1rem 0;
|
|
267
|
+
color: hsl(var(--muted-foreground));
|
|
268
|
+
font-style: italic;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* Horizontal rules */
|
|
272
|
+
.prd-viewer .prose hr {
|
|
273
|
+
border: none;
|
|
274
|
+
border-top: 1px solid hsl(var(--border));
|
|
275
|
+
margin: 2rem 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* Images */
|
|
279
|
+
.prd-viewer .prose img {
|
|
280
|
+
max-width: 100%;
|
|
281
|
+
height: auto;
|
|
282
|
+
border-radius: 0.5rem;
|
|
283
|
+
margin: 1rem 0;
|
|
284
|
+
}
|
|
285
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Task, TaskStatus } from '~/types/task'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
tasks: Task[]
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
taskClick: [task: Task]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
// Extract task number from ID (e.g., "task-001" -> 1)
|
|
13
|
+
function getTaskNumber(task: Task): number {
|
|
14
|
+
const match = task.id.match(/(\d+)$/)
|
|
15
|
+
const value = match?.[1]
|
|
16
|
+
return value ? parseInt(value, 10) : 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Group tasks by status and sort them
|
|
20
|
+
// Pending/In Progress: lowest number first (ascending)
|
|
21
|
+
// Completed: highest number first (descending)
|
|
22
|
+
const pendingTasks = computed(() =>
|
|
23
|
+
props.tasks
|
|
24
|
+
.filter(t => t.status === 'pending')
|
|
25
|
+
.sort((a, b) => getTaskNumber(a) - getTaskNumber(b))
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const inProgressTasks = computed(() =>
|
|
29
|
+
props.tasks
|
|
30
|
+
.filter(t => t.status === 'in_progress')
|
|
31
|
+
.sort((a, b) => getTaskNumber(a) - getTaskNumber(b))
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const completedTasks = computed(() =>
|
|
35
|
+
props.tasks
|
|
36
|
+
.filter(t => t.status === 'completed')
|
|
37
|
+
.sort((a, b) => {
|
|
38
|
+
// Sort by completedAt descending (most recently completed first)
|
|
39
|
+
const aTime = a.completedAt ? new Date(a.completedAt).getTime() : 0
|
|
40
|
+
const bTime = b.completedAt ? new Date(b.completedAt).getTime() : 0
|
|
41
|
+
return bTime - aTime
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// Build a map of task ID -> array of incomplete blocking task IDs
|
|
46
|
+
const blockedByMap = computed(() => {
|
|
47
|
+
const map = new Map<string, string[]>()
|
|
48
|
+
const completedIds = new Set(completedTasks.value.map(t => t.id))
|
|
49
|
+
|
|
50
|
+
for (const task of props.tasks) {
|
|
51
|
+
if (task.dependencies.length > 0) {
|
|
52
|
+
// Filter to only incomplete dependencies
|
|
53
|
+
const blockers = task.dependencies.filter(depId => !completedIds.has(depId))
|
|
54
|
+
if (blockers.length > 0) {
|
|
55
|
+
map.set(task.id, blockers)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return map
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Column definitions
|
|
64
|
+
const columns: { status: TaskStatus; tasks: typeof pendingTasks }[] = [
|
|
65
|
+
{ status: 'pending', tasks: pendingTasks },
|
|
66
|
+
{ status: 'in_progress', tasks: inProgressTasks },
|
|
67
|
+
{ status: 'completed', tasks: completedTasks }
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function handleTaskClick(task: Task) {
|
|
71
|
+
emit('taskClick', task)
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div class="flex h-full gap-3 overflow-x-auto pb-2">
|
|
77
|
+
<TasksColumn
|
|
78
|
+
v-for="column in columns"
|
|
79
|
+
:key="column.status"
|
|
80
|
+
:status="column.status"
|
|
81
|
+
:tasks="column.tasks.value"
|
|
82
|
+
:blocked-by-map="blockedByMap"
|
|
83
|
+
@task-click="handleTaskClick"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { AlertCircle, ArrowUp, ArrowDown, Minus } from 'lucide-vue-next'
|
|
3
|
+
import { Card, CardContent } from '~/components/ui/card'
|
|
4
|
+
import { Badge } from '~/components/ui/badge'
|
|
5
|
+
import type { Task } from '~/types/task'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
task: Task
|
|
9
|
+
/** IDs of tasks that are blocking this one (not completed) */
|
|
10
|
+
blockedBy?: string[]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
click: [task: Task]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
// Category badge styling
|
|
18
|
+
const categoryConfig = computed(() => {
|
|
19
|
+
switch (props.task.category) {
|
|
20
|
+
case 'setup':
|
|
21
|
+
return { label: 'Setup', variant: 'secondary' as const }
|
|
22
|
+
case 'feature':
|
|
23
|
+
return { label: 'Feature', variant: 'default' as const }
|
|
24
|
+
case 'integration':
|
|
25
|
+
return { label: 'Integration', variant: 'outline' as const }
|
|
26
|
+
case 'testing':
|
|
27
|
+
return { label: 'Testing', variant: 'secondary' as const }
|
|
28
|
+
case 'documentation':
|
|
29
|
+
return { label: 'Docs', variant: 'secondary' as const }
|
|
30
|
+
default:
|
|
31
|
+
return { label: props.task.category, variant: 'secondary' as const }
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Priority indicator
|
|
36
|
+
const priorityConfig = computed(() => {
|
|
37
|
+
switch (props.task.priority) {
|
|
38
|
+
case 'critical':
|
|
39
|
+
return { icon: ArrowUp, class: 'text-destructive', label: 'Critical' }
|
|
40
|
+
case 'high':
|
|
41
|
+
return { icon: ArrowUp, class: 'text-orange-500', label: 'High' }
|
|
42
|
+
case 'medium':
|
|
43
|
+
return { icon: Minus, class: 'text-muted-foreground', label: 'Medium' }
|
|
44
|
+
case 'low':
|
|
45
|
+
return { icon: ArrowDown, class: 'text-muted-foreground', label: 'Low' }
|
|
46
|
+
default:
|
|
47
|
+
return { icon: Minus, class: 'text-muted-foreground', label: 'Unknown' }
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Check if task is blocked
|
|
52
|
+
const isBlocked = computed(() => {
|
|
53
|
+
return props.blockedBy && props.blockedBy.length > 0
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Extract task number from ID (e.g., "task-001" -> 1)
|
|
57
|
+
const taskNumber = computed(() => {
|
|
58
|
+
const match = props.task.id.match(/(\d+)$/)
|
|
59
|
+
const value = match?.[1]
|
|
60
|
+
return value ? parseInt(value, 10) : 0
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const blockedCount = computed(() => {
|
|
64
|
+
return props.blockedBy?.length ?? 0
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function handleClick() {
|
|
68
|
+
emit('click', props.task)
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<Card
|
|
74
|
+
class="cursor-pointer py-0 gap-0 transition-all hover:shadow-md hover:border-primary/50"
|
|
75
|
+
:class="{ 'opacity-60': isBlocked }"
|
|
76
|
+
@click="handleClick"
|
|
77
|
+
>
|
|
78
|
+
<CardContent class="p-2.5">
|
|
79
|
+
<!-- Header: Category + Priority -->
|
|
80
|
+
<div class="flex items-center justify-between gap-2 mb-1.5">
|
|
81
|
+
<Badge :variant="categoryConfig.variant" class="text-xs">
|
|
82
|
+
{{ categoryConfig.label }}
|
|
83
|
+
</Badge>
|
|
84
|
+
<component
|
|
85
|
+
:is="priorityConfig.icon"
|
|
86
|
+
class="size-4"
|
|
87
|
+
:class="priorityConfig.class"
|
|
88
|
+
:title="priorityConfig.label"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Title -->
|
|
93
|
+
<h4 class="text-sm font-medium leading-snug">
|
|
94
|
+
<span class="text-muted-foreground">#{{ taskNumber }}</span>
|
|
95
|
+
{{ task.title }}
|
|
96
|
+
</h4>
|
|
97
|
+
|
|
98
|
+
<!-- Blocked indicator -->
|
|
99
|
+
<div
|
|
100
|
+
v-if="isBlocked"
|
|
101
|
+
class="mt-2 flex items-center gap-1.5 text-xs text-destructive"
|
|
102
|
+
>
|
|
103
|
+
<AlertCircle class="size-3.5" />
|
|
104
|
+
<span>Blocked by {{ blockedCount }} task{{ blockedCount === 1 ? '' : 's' }}</span>
|
|
105
|
+
</div>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
</template>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Circle, PlayCircle, CheckCircle2 } from 'lucide-vue-next'
|
|
3
|
+
import type { Task, TaskStatus } from '~/types/task'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
status: TaskStatus
|
|
7
|
+
tasks: Task[]
|
|
8
|
+
/** Map of task ID to array of blocking task IDs */
|
|
9
|
+
blockedByMap?: Map<string, string[]>
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
taskClick: [task: Task]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
// Status configuration for styling
|
|
17
|
+
const statusConfig = computed(() => {
|
|
18
|
+
switch (props.status) {
|
|
19
|
+
case 'pending':
|
|
20
|
+
return {
|
|
21
|
+
label: 'Pending',
|
|
22
|
+
icon: Circle,
|
|
23
|
+
headerClass: 'bg-muted/50',
|
|
24
|
+
iconClass: 'text-muted-foreground'
|
|
25
|
+
}
|
|
26
|
+
case 'in_progress':
|
|
27
|
+
return {
|
|
28
|
+
label: 'In Progress',
|
|
29
|
+
icon: PlayCircle,
|
|
30
|
+
headerClass: 'bg-blue-500/10',
|
|
31
|
+
iconClass: 'text-blue-500'
|
|
32
|
+
}
|
|
33
|
+
case 'completed':
|
|
34
|
+
return {
|
|
35
|
+
label: 'Completed',
|
|
36
|
+
icon: CheckCircle2,
|
|
37
|
+
headerClass: 'bg-green-500/10',
|
|
38
|
+
iconClass: 'text-green-500'
|
|
39
|
+
}
|
|
40
|
+
default:
|
|
41
|
+
return {
|
|
42
|
+
label: props.status,
|
|
43
|
+
icon: Circle,
|
|
44
|
+
headerClass: 'bg-muted/50',
|
|
45
|
+
iconClass: 'text-muted-foreground'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function getBlockedBy(taskId: string): string[] {
|
|
51
|
+
return props.blockedByMap?.get(taskId) ?? []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleTaskClick(task: Task) {
|
|
55
|
+
emit('taskClick', task)
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div class="flex h-full min-w-56 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
61
|
+
<!-- Column Header -->
|
|
62
|
+
<div
|
|
63
|
+
class="flex items-center gap-2 rounded-t-lg border-b border-border px-3 py-2"
|
|
64
|
+
:class="statusConfig.headerClass"
|
|
65
|
+
>
|
|
66
|
+
<component
|
|
67
|
+
:is="statusConfig.icon"
|
|
68
|
+
class="size-4"
|
|
69
|
+
:class="statusConfig.iconClass"
|
|
70
|
+
/>
|
|
71
|
+
<h3 class="text-sm font-medium">{{ statusConfig.label }}</h3>
|
|
72
|
+
<span class="ml-auto text-xs text-muted-foreground">
|
|
73
|
+
{{ tasks.length }}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<!-- Task Cards -->
|
|
78
|
+
<div class="scrollbar-hide min-h-0 flex-1 overflow-y-auto p-1.5">
|
|
79
|
+
<div class="space-y-1.5">
|
|
80
|
+
<TasksCard
|
|
81
|
+
v-for="task in tasks"
|
|
82
|
+
:key="task.id"
|
|
83
|
+
:task="task"
|
|
84
|
+
:blocked-by="getBlockedBy(task.id)"
|
|
85
|
+
@click="handleTaskClick"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<!-- Empty state -->
|
|
89
|
+
<div
|
|
90
|
+
v-if="tasks.length === 0"
|
|
91
|
+
class="py-8 text-center text-sm text-muted-foreground"
|
|
92
|
+
>
|
|
93
|
+
No tasks
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</template>
|
|
99
|
+
|
|
100
|
+
<style scoped>
|
|
101
|
+
.scrollbar-hide {
|
|
102
|
+
-ms-overflow-style: none;
|
|
103
|
+
scrollbar-width: none;
|
|
104
|
+
}
|
|
105
|
+
.scrollbar-hide::-webkit-scrollbar {
|
|
106
|
+
display: none;
|
|
107
|
+
}
|
|
108
|
+
</style>
|