@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.
Files changed (154) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/app/app.vue +14 -0
  5. package/app/assets/css/main.css +129 -0
  6. package/app/components/CommandPalette.vue +182 -0
  7. package/app/components/ShortcutsHelp.vue +85 -0
  8. package/app/components/git/ChangesMinimap.vue +143 -0
  9. package/app/components/git/CommitList.vue +224 -0
  10. package/app/components/git/DiffPanel.vue +402 -0
  11. package/app/components/git/DiffViewer.vue +803 -0
  12. package/app/components/layout/RepoSelector.vue +358 -0
  13. package/app/components/layout/Sidebar.vue +91 -0
  14. package/app/components/prd/Meta.vue +69 -0
  15. package/app/components/prd/Viewer.vue +285 -0
  16. package/app/components/tasks/Board.vue +86 -0
  17. package/app/components/tasks/Card.vue +108 -0
  18. package/app/components/tasks/Column.vue +108 -0
  19. package/app/components/tasks/Detail.vue +291 -0
  20. package/app/components/ui/badge/Badge.vue +26 -0
  21. package/app/components/ui/badge/index.ts +26 -0
  22. package/app/components/ui/button/Button.vue +29 -0
  23. package/app/components/ui/button/index.ts +38 -0
  24. package/app/components/ui/card/Card.vue +22 -0
  25. package/app/components/ui/card/CardAction.vue +17 -0
  26. package/app/components/ui/card/CardContent.vue +17 -0
  27. package/app/components/ui/card/CardDescription.vue +17 -0
  28. package/app/components/ui/card/CardFooter.vue +17 -0
  29. package/app/components/ui/card/CardHeader.vue +17 -0
  30. package/app/components/ui/card/CardTitle.vue +17 -0
  31. package/app/components/ui/card/index.ts +7 -0
  32. package/app/components/ui/combobox/Combobox.vue +19 -0
  33. package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
  34. package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
  35. package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
  36. package/app/components/ui/combobox/ComboboxInput.vue +42 -0
  37. package/app/components/ui/combobox/ComboboxItem.vue +24 -0
  38. package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
  39. package/app/components/ui/combobox/ComboboxList.vue +33 -0
  40. package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
  41. package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
  42. package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
  43. package/app/components/ui/combobox/index.ts +13 -0
  44. package/app/components/ui/command/Command.vue +103 -0
  45. package/app/components/ui/command/CommandDialog.vue +33 -0
  46. package/app/components/ui/command/CommandEmpty.vue +27 -0
  47. package/app/components/ui/command/CommandGroup.vue +45 -0
  48. package/app/components/ui/command/CommandInput.vue +54 -0
  49. package/app/components/ui/command/CommandItem.vue +76 -0
  50. package/app/components/ui/command/CommandList.vue +25 -0
  51. package/app/components/ui/command/CommandSeparator.vue +21 -0
  52. package/app/components/ui/command/CommandShortcut.vue +17 -0
  53. package/app/components/ui/command/index.ts +25 -0
  54. package/app/components/ui/dialog/Dialog.vue +19 -0
  55. package/app/components/ui/dialog/DialogClose.vue +15 -0
  56. package/app/components/ui/dialog/DialogContent.vue +53 -0
  57. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  58. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  59. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  60. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  61. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  62. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  63. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  64. package/app/components/ui/dialog/index.ts +10 -0
  65. package/app/components/ui/input/Input.vue +33 -0
  66. package/app/components/ui/input/index.ts +1 -0
  67. package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
  68. package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
  69. package/app/components/ui/scroll-area/index.ts +2 -0
  70. package/app/components/ui/separator/Separator.vue +29 -0
  71. package/app/components/ui/separator/index.ts +1 -0
  72. package/app/components/ui/sheet/Sheet.vue +19 -0
  73. package/app/components/ui/sheet/SheetClose.vue +15 -0
  74. package/app/components/ui/sheet/SheetContent.vue +62 -0
  75. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  76. package/app/components/ui/sheet/SheetFooter.vue +16 -0
  77. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  78. package/app/components/ui/sheet/SheetOverlay.vue +21 -0
  79. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  80. package/app/components/ui/sheet/SheetTrigger.vue +15 -0
  81. package/app/components/ui/sheet/index.ts +8 -0
  82. package/app/components/ui/tabs/Tabs.vue +24 -0
  83. package/app/components/ui/tabs/TabsContent.vue +21 -0
  84. package/app/components/ui/tabs/TabsList.vue +24 -0
  85. package/app/components/ui/tabs/TabsTrigger.vue +26 -0
  86. package/app/components/ui/tabs/index.ts +4 -0
  87. package/app/components/ui/tooltip/Tooltip.vue +19 -0
  88. package/app/components/ui/tooltip/TooltipContent.vue +34 -0
  89. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  90. package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
  91. package/app/components/ui/tooltip/index.ts +4 -0
  92. package/app/composables/useFileWatch.ts +78 -0
  93. package/app/composables/useGit.ts +180 -0
  94. package/app/composables/useKeyboard.ts +180 -0
  95. package/app/composables/usePrd.ts +86 -0
  96. package/app/composables/useRepos.ts +108 -0
  97. package/app/composables/useThemeMode.ts +38 -0
  98. package/app/composables/useToast.ts +31 -0
  99. package/app/layouts/default.vue +197 -0
  100. package/app/lib/utils.ts +7 -0
  101. package/app/pages/[repo]/[prd].vue +263 -0
  102. package/app/pages/index.vue +257 -0
  103. package/app/types/git.ts +81 -0
  104. package/app/types/index.ts +29 -0
  105. package/app/types/prd.ts +49 -0
  106. package/app/types/repo.ts +37 -0
  107. package/app/types/task.ts +134 -0
  108. package/bin/prd +21 -0
  109. package/components.json +21 -0
  110. package/dist/app/types/git.js +1 -0
  111. package/dist/app/types/prd.js +1 -0
  112. package/dist/app/types/repo.js +1 -0
  113. package/dist/app/types/task.js +1 -0
  114. package/dist/host/src/api/git.js +96 -0
  115. package/dist/host/src/api/index.js +4 -0
  116. package/dist/host/src/api/prds.js +195 -0
  117. package/dist/host/src/api/repos.js +47 -0
  118. package/dist/host/src/api/state.js +63 -0
  119. package/dist/host/src/executor.js +109 -0
  120. package/dist/host/src/index.js +95 -0
  121. package/dist/host/src/mcp.js +62 -0
  122. package/dist/host/src/ui.js +64 -0
  123. package/dist/server/utils/db.js +125 -0
  124. package/dist/server/utils/git.js +396 -0
  125. package/dist/server/utils/prd-state.js +229 -0
  126. package/dist/server/utils/repos.js +256 -0
  127. package/docs/MCP.md +180 -0
  128. package/nuxt.config.ts +34 -0
  129. package/package.json +88 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/robots.txt +1 -0
  132. package/server/api/browse.get.ts +52 -0
  133. package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
  134. package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
  135. package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
  136. package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
  137. package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
  138. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
  139. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
  140. package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
  141. package/server/api/repos/[repoId]/prds.get.ts +85 -0
  142. package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
  143. package/server/api/repos/[repoId].delete.ts +27 -0
  144. package/server/api/repos/index.get.ts +5 -0
  145. package/server/api/repos/index.post.ts +39 -0
  146. package/server/api/watch.get.ts +63 -0
  147. package/server/plugins/migrate-legacy-state.ts +19 -0
  148. package/server/tsconfig.json +3 -0
  149. package/server/utils/db.ts +169 -0
  150. package/server/utils/git.ts +478 -0
  151. package/server/utils/prd-state.ts +335 -0
  152. package/server/utils/repos.ts +322 -0
  153. package/server/utils/watcher.ts +179 -0
  154. package/tsconfig.json +4 -0
@@ -0,0 +1,358 @@
1
+ <script setup lang="ts">
2
+ import { onClickOutside } from '@vueuse/core'
3
+ import { ChevronDown, Plus, FolderOpen, Check, Trash2, Folder } from 'lucide-vue-next'
4
+ import {
5
+ Sheet,
6
+ SheetContent,
7
+ SheetDescription,
8
+ SheetFooter,
9
+ SheetHeader,
10
+ SheetTitle
11
+ } from '~/components/ui/sheet'
12
+ import { Button } from '~/components/ui/button'
13
+ import { Input } from '~/components/ui/input'
14
+
15
+ const router = useRouter()
16
+ const route = useRoute()
17
+ const { repos, currentRepo, currentRepoId, selectRepo, addRepo, removeRepo } = useRepos()
18
+ const { showSuccess, showError } = useToast()
19
+
20
+ // Dropdown state
21
+ const open = ref(false)
22
+ const searchQuery = ref('')
23
+ const dropdownRef = ref<HTMLElement | null>(null)
24
+
25
+ // Close dropdown when clicking outside
26
+ onClickOutside(dropdownRef, () => {
27
+ if (open.value) {
28
+ open.value = false
29
+ searchQuery.value = ''
30
+ }
31
+ })
32
+
33
+ // Close dropdown when route changes (e.g., command palette navigation)
34
+ watch(() => route.fullPath, () => {
35
+ open.value = false
36
+ searchQuery.value = ''
37
+ })
38
+
39
+ async function handleSelectRepo(id: string) {
40
+ selectRepo(id)
41
+ open.value = false
42
+ searchQuery.value = ''
43
+
44
+ // Fetch PRDs for new repo and navigate to first one
45
+ try {
46
+ const prds = await $fetch<{ slug: string }[]>(`/api/repos/${id}/prds`)
47
+ const firstPrd = prds?.[0]
48
+ if (firstPrd) {
49
+ router.push(`/${id}/${firstPrd.slug}`)
50
+ } else {
51
+ router.push('/')
52
+ }
53
+ } catch {
54
+ router.push('/')
55
+ }
56
+ }
57
+
58
+ function handleAddClick() {
59
+ open.value = false
60
+ showAddDialog.value = true
61
+ }
62
+
63
+ // Directory browser state
64
+ const showBrowser = ref(false)
65
+ const browserPath = ref('')
66
+ const browserDirs = ref<{ name: string; path: string }[]>([])
67
+ const browserLoading = ref(false)
68
+
69
+ async function browseDirectory(path?: string) {
70
+ browserLoading.value = true
71
+ try {
72
+ const data = await $fetch('/api/browse', {
73
+ query: { path: path || browserPath.value || undefined }
74
+ })
75
+ browserPath.value = data.current
76
+ browserDirs.value = data.directories
77
+ } catch {
78
+ // Silently fail - directory browser will show empty state
79
+ } finally {
80
+ browserLoading.value = false
81
+ }
82
+ }
83
+
84
+ function openBrowser() {
85
+ showBrowser.value = true
86
+ browseDirectory(newRepoPath.value || undefined)
87
+ }
88
+
89
+ function selectDirectory(path: string) {
90
+ newRepoPath.value = path
91
+ showBrowser.value = false
92
+ addError.value = null
93
+ }
94
+
95
+ function navigateUp() {
96
+ const parent = browserPath.value.split('/').slice(0, -1).join('/') || '/'
97
+ browseDirectory(parent)
98
+ }
99
+
100
+ // Add repo dialog state
101
+ const showAddDialog = ref(false)
102
+ const newRepoPath = ref('')
103
+ const addError = ref<string | null>(null)
104
+ const isAdding = ref(false)
105
+
106
+ // Filtered repos based on search
107
+ const filteredRepos = computed(() => {
108
+ if (!searchQuery.value) return repos.value
109
+ const query = searchQuery.value.toLowerCase()
110
+ return repos.value?.filter(
111
+ repo => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query)
112
+ ) ?? []
113
+ })
114
+
115
+
116
+ async function handleAddRepo() {
117
+ if (!newRepoPath.value.trim()) {
118
+ addError.value = 'Please enter a repository path'
119
+ return
120
+ }
121
+
122
+ isAdding.value = true
123
+ addError.value = null
124
+
125
+ try {
126
+ const newRepo = await addRepo(newRepoPath.value.trim())
127
+ showAddDialog.value = false
128
+ newRepoPath.value = ''
129
+ showSuccess('Repository added', newRepo?.name || 'Successfully added repository')
130
+
131
+ // Navigate to first PRD of newly added repo
132
+ if (newRepo?.id) {
133
+ try {
134
+ const prds = await $fetch<{ slug: string }[]>(`/api/repos/${newRepo.id}/prds`)
135
+ const firstPrd = prds?.[0]
136
+ if (firstPrd) {
137
+ router.push(`/${newRepo.id}/${firstPrd.slug}`)
138
+ }
139
+ } catch {
140
+ // Ignore navigation errors
141
+ }
142
+ }
143
+ } catch (error) {
144
+ if (error instanceof Error) {
145
+ // Extract message from fetch error
146
+ const fetchError = error as { data?: { message?: string } }
147
+ addError.value = fetchError.data?.message || error.message
148
+ } else {
149
+ addError.value = 'Failed to add repository'
150
+ }
151
+ } finally {
152
+ isAdding.value = false
153
+ }
154
+ }
155
+
156
+ async function handleRemoveRepo(event: Event, repoId: string) {
157
+ event.stopPropagation()
158
+ await removeRepo(repoId)
159
+ }
160
+
161
+ function handleDialogClose() {
162
+ showAddDialog.value = false
163
+ newRepoPath.value = ''
164
+ addError.value = null
165
+ }
166
+
167
+ // Expose method to open the add dialog from outside (e.g., via keyboard shortcut)
168
+ defineExpose({
169
+ openAddDialog: () => {
170
+ showAddDialog.value = true
171
+ }
172
+ })
173
+ </script>
174
+
175
+ <template>
176
+ <div ref="dropdownRef" class="relative">
177
+ <!-- Simple dropdown for repo selection -->
178
+ <button
179
+ type="button"
180
+ class="inline-flex h-8 w-[200px] items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm font-normal ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
181
+ @click="open = !open"
182
+ >
183
+ <span class="flex items-center gap-2 truncate">
184
+ <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
185
+ <span class="truncate">
186
+ {{ currentRepo?.name ?? 'Select repository' }}
187
+ </span>
188
+ </span>
189
+ <ChevronDown class="size-4 shrink-0 opacity-50" />
190
+ </button>
191
+
192
+ <!-- Dropdown content -->
193
+ <div
194
+ v-if="open"
195
+ class="absolute top-full right-0 z-[9999] mt-1 w-[280px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
196
+ >
197
+ <!-- Search input -->
198
+ <input
199
+ v-model="searchQuery"
200
+ type="text"
201
+ placeholder="Search repositories..."
202
+ class="mb-1 h-9 w-full rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
203
+ />
204
+
205
+ <!-- Repository items -->
206
+ <div class="max-h-[200px] overflow-y-auto">
207
+ <template v-if="filteredRepos?.length">
208
+ <div
209
+ v-for="repo in filteredRepos"
210
+ :key="repo.id"
211
+ class="group flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
212
+ role="option"
213
+ :aria-selected="currentRepoId === repo.id"
214
+ @click="handleSelectRepo(repo.id)"
215
+ >
216
+ <Check
217
+ class="size-4 shrink-0"
218
+ :class="currentRepoId === repo.id ? 'opacity-100' : 'opacity-0'"
219
+ />
220
+ <div class="flex flex-1 flex-col gap-0.5 overflow-hidden text-left">
221
+ <span class="truncate font-medium">{{ repo.name }}</span>
222
+ <span class="truncate text-xs text-muted-foreground">{{ repo.path }}</span>
223
+ </div>
224
+ <button
225
+ type="button"
226
+ class="ml-auto opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 rounded transition-opacity"
227
+ title="Remove repository"
228
+ @click="handleRemoveRepo($event, repo.id)"
229
+ >
230
+ <Trash2 class="size-3.5 text-destructive" />
231
+ </button>
232
+ </div>
233
+ </template>
234
+ <div v-else class="px-2 py-1.5 text-sm text-muted-foreground">
235
+ No repositories found.
236
+ </div>
237
+
238
+ <!-- Separator -->
239
+ <div v-if="filteredRepos?.length" class="my-1 h-px bg-border" />
240
+
241
+ <!-- Add repository option -->
242
+ <button
243
+ type="button"
244
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
245
+ @click="handleAddClick"
246
+ >
247
+ <Plus class="size-4" />
248
+ <span>Add repository...</span>
249
+ </button>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- Add Repository Sheet -->
254
+ <Sheet :open="showAddDialog" @update:open="showAddDialog = $event">
255
+ <SheetContent side="right" class="flex h-full flex-col" @escape-key-down="handleDialogClose">
256
+ <SheetHeader class="px-6">
257
+ <SheetTitle>Add Repository</SheetTitle>
258
+ <SheetDescription>
259
+ Enter the absolute path to a repository containing PRD documents.
260
+ </SheetDescription>
261
+ </SheetHeader>
262
+
263
+ <form id="add-repo-form" class="min-h-0 flex-1 space-y-4 overflow-y-auto px-6" @submit.prevent="handleAddRepo">
264
+ <div class="space-y-2">
265
+ <label for="repo-path" class="text-sm font-medium">
266
+ Repository Path
267
+ </label>
268
+ <div class="flex gap-2">
269
+ <Input
270
+ id="repo-path"
271
+ v-model="newRepoPath"
272
+ placeholder="/path/to/your/project"
273
+ :aria-invalid="!!addError"
274
+ :disabled="isAdding"
275
+ class="flex-1"
276
+ />
277
+ <Button
278
+ type="button"
279
+ variant="outline"
280
+ size="icon"
281
+ :disabled="isAdding"
282
+ @click="openBrowser"
283
+ >
284
+ <Folder class="size-4" />
285
+ </Button>
286
+ </div>
287
+ <p v-if="addError" class="text-sm text-destructive">
288
+ {{ addError }}
289
+ </p>
290
+ <p class="text-xs text-muted-foreground">
291
+ The repository should contain a <code class="rounded bg-muted px-1">docs/prd/</code> directory with markdown files.
292
+ </p>
293
+ </div>
294
+
295
+ <!-- Directory Browser -->
296
+ <div v-if="showBrowser" class="space-y-2 rounded-md border p-3">
297
+ <div class="flex items-center gap-2 text-sm">
298
+ <button
299
+ type="button"
300
+ class="hover:text-foreground text-muted-foreground"
301
+ :disabled="browserPath === '/'"
302
+ @click="navigateUp"
303
+ >
304
+ ..
305
+ </button>
306
+ <span class="flex-1 truncate font-mono text-xs text-muted-foreground">
307
+ {{ browserPath }}
308
+ </span>
309
+ <Button
310
+ type="button"
311
+ variant="ghost"
312
+ size="sm"
313
+ class="h-7 text-xs"
314
+ @click="selectDirectory(browserPath)"
315
+ >
316
+ Select
317
+ </Button>
318
+ </div>
319
+ <div class="max-h-[200px] overflow-y-auto">
320
+ <div v-if="browserLoading" class="py-2 text-center text-sm text-muted-foreground">
321
+ Loading...
322
+ </div>
323
+ <div v-else-if="!browserDirs.length" class="py-2 text-center text-sm text-muted-foreground">
324
+ No subdirectories
325
+ </div>
326
+ <button
327
+ v-for="dir in browserDirs"
328
+ v-else
329
+ :key="dir.path"
330
+ type="button"
331
+ class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-accent"
332
+ @click="browseDirectory(dir.path)"
333
+ >
334
+ <Folder class="size-4 text-muted-foreground" />
335
+ <span class="truncate">{{ dir.name }}</span>
336
+ </button>
337
+ </div>
338
+ </div>
339
+ </form>
340
+
341
+ <SheetFooter class="px-6 pb-6">
342
+ <Button
343
+ type="button"
344
+ variant="outline"
345
+ :disabled="isAdding"
346
+ @click="handleDialogClose"
347
+ >
348
+ Cancel
349
+ </Button>
350
+ <Button type="submit" form="add-repo-form" :disabled="isAdding">
351
+ <span v-if="isAdding">Adding...</span>
352
+ <span v-else>Add Repository</span>
353
+ </Button>
354
+ </SheetFooter>
355
+ </SheetContent>
356
+ </Sheet>
357
+ </div>
358
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ import { FileText, Loader2, AlertCircle, RefreshCw } from 'lucide-vue-next'
3
+ import { ScrollArea } from '~/components/ui/scroll-area'
4
+ import { Badge } from '~/components/ui/badge'
5
+ import { Button } from '~/components/ui/button'
6
+
7
+ const route = useRoute()
8
+ const { prds, prdsStatus, refreshPrds } = usePrd()
9
+ const { currentRepoId } = useRepos()
10
+
11
+ // Determine which PRD is currently selected based on route
12
+ const currentPrdSlug = computed(() => {
13
+ return route.params.prd as string | undefined
14
+ })
15
+
16
+ // Check if a PRD is the current one
17
+ function isActive(slug: string): boolean {
18
+ return currentPrdSlug.value === slug
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <aside class="flex h-full w-64 flex-col border-r border-border bg-background">
24
+ <!-- PRD List -->
25
+ <ScrollArea class="flex-1">
26
+ <div class="p-2">
27
+ <!-- Documents Header -->
28
+ <h2 class="flex h-10 items-center px-2 text-sm font-medium text-muted-foreground">Documents</h2>
29
+ <!-- Loading state -->
30
+ <div v-if="prdsStatus === 'pending'" class="flex items-center justify-center py-8">
31
+ <Loader2 class="size-5 animate-spin text-muted-foreground" />
32
+ </div>
33
+
34
+ <!-- No repo selected -->
35
+ <div v-else-if="!currentRepoId" class="px-2 py-8 text-center">
36
+ <p class="text-sm text-muted-foreground">
37
+ Select a repository to view PRDs
38
+ </p>
39
+ </div>
40
+
41
+ <!-- Error state -->
42
+ <div v-else-if="prdsStatus === 'error'" class="px-2 py-8 text-center">
43
+ <AlertCircle class="mx-auto size-8 text-destructive/50" />
44
+ <p class="mt-2 text-sm text-muted-foreground">
45
+ Failed to load PRDs
46
+ </p>
47
+ <Button variant="ghost" size="sm" class="mt-2" @click="refreshPrds">
48
+ <RefreshCw class="mr-1 size-3" />
49
+ Retry
50
+ </Button>
51
+ </div>
52
+
53
+ <!-- Empty state -->
54
+ <div v-else-if="!prds?.length" class="px-2 py-8 text-center">
55
+ <FileText class="mx-auto size-8 text-muted-foreground/50" />
56
+ <p class="mt-2 text-sm text-muted-foreground">
57
+ No PRDs found
58
+ </p>
59
+ <p class="mt-1 text-xs text-muted-foreground/70">
60
+ Add .md files to docs/prd/
61
+ </p>
62
+ </div>
63
+
64
+ <!-- PRD items -->
65
+ <nav v-else class="space-y-1">
66
+ <NuxtLink
67
+ v-for="prd in prds"
68
+ :key="prd.slug"
69
+ :to="`/${currentRepoId}/${prd.slug}`"
70
+ class="group flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors"
71
+ :class="[
72
+ isActive(prd.slug)
73
+ ? 'bg-accent text-accent-foreground'
74
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
75
+ ]"
76
+ >
77
+ <FileText class="size-4 shrink-0" />
78
+ <span class="flex-1 truncate">{{ prd.name }}</span>
79
+ <Badge
80
+ v-if="prd.hasState && prd.taskCount"
81
+ variant="secondary"
82
+ class="shrink-0 text-xs"
83
+ >
84
+ {{ prd.completedCount ?? 0 }}/{{ prd.taskCount }}
85
+ </Badge>
86
+ </NuxtLink>
87
+ </nav>
88
+ </div>
89
+ </ScrollArea>
90
+ </aside>
91
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { User, Calendar, CircleDot, ExternalLink } from 'lucide-vue-next'
3
+ import { Badge } from '~/components/ui/badge'
4
+ import type { PrdMetadata } from '~/types/prd'
5
+
6
+ const props = defineProps<{
7
+ metadata: PrdMetadata
8
+ }>()
9
+
10
+ // Determine status color based on status text
11
+ const statusVariant = computed(() => {
12
+ const status = props.metadata.status?.toLowerCase()
13
+ if (!status) return 'secondary'
14
+ if (status.includes('complete') || status.includes('done')) return 'default'
15
+ if (status.includes('progress') || status.includes('active')) return 'default'
16
+ if (status.includes('draft')) return 'secondary'
17
+ if (status.includes('blocked') || status.includes('paused')) return 'destructive'
18
+ return 'secondary'
19
+ })
20
+
21
+ // Check if there's any metadata to display
22
+ const hasMetadata = computed(() => {
23
+ return props.metadata.author ||
24
+ props.metadata.date ||
25
+ props.metadata.status ||
26
+ props.metadata.shortcutStory
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <div v-if="hasMetadata" class="flex flex-wrap items-center gap-3 text-sm">
32
+ <!-- Author -->
33
+ <div v-if="metadata.author" class="flex items-center gap-1.5 text-muted-foreground">
34
+ <User class="size-3.5" />
35
+ <span>{{ metadata.author }}</span>
36
+ </div>
37
+
38
+ <!-- Date -->
39
+ <div v-if="metadata.date" class="flex items-center gap-1.5 text-muted-foreground">
40
+ <Calendar class="size-3.5" />
41
+ <span>{{ metadata.date }}</span>
42
+ </div>
43
+
44
+ <!-- Status -->
45
+ <Badge v-if="metadata.status" :variant="statusVariant" class="gap-1">
46
+ <CircleDot class="size-3" />
47
+ {{ metadata.status }}
48
+ </Badge>
49
+
50
+ <!-- Shortcut Story Link -->
51
+ <a
52
+ v-if="metadata.shortcutStory && metadata.shortcutUrl"
53
+ :href="metadata.shortcutUrl"
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ class="inline-flex items-center gap-1.5 text-primary hover:underline"
57
+ >
58
+ <ExternalLink class="size-3.5" />
59
+ <span>{{ metadata.shortcutStory }}</span>
60
+ </a>
61
+ <span
62
+ v-else-if="metadata.shortcutStory"
63
+ class="flex items-center gap-1.5 text-muted-foreground"
64
+ >
65
+ <ExternalLink class="size-3.5" />
66
+ <span>{{ metadata.shortcutStory }}</span>
67
+ </span>
68
+ </div>
69
+ </template>