eeb6174de0
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
486 lines
12 KiB
Vue
486 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter, Loader2 } from '@lucide/vue'
|
|
import { apiFetch } from '../services/api'
|
|
import { renderMarkdown } from '../utils/markdown'
|
|
import type { DocFile, DocDetail } from '../types'
|
|
|
|
// State
|
|
const docs = ref<DocFile[]>([])
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
|
|
const searchQuery = ref('')
|
|
const categoryFilter = ref('')
|
|
|
|
const selectedDoc = ref<DocDetail | null>(null)
|
|
const contentLoading = ref(false)
|
|
|
|
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
|
|
|
|
// Filtered + sorted docs (newest first)
|
|
const filteredDocs = computed(() => {
|
|
let items = [...docs.value]
|
|
if (categoryFilter.value) {
|
|
items = items.filter(d => d.category === categoryFilter.value)
|
|
}
|
|
if (searchQuery.value.trim()) {
|
|
const q = searchQuery.value.trim().toLowerCase()
|
|
items = items.filter(d => d.name.toLowerCase().includes(q) || d.path.toLowerCase().includes(q))
|
|
}
|
|
return items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
|
|
})
|
|
|
|
async function loadDocs() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await apiFetch('/api/v1/docs')
|
|
if (!response.ok) throw new Error('Failed to load documents')
|
|
docs.value = await response.json()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load documents'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadDocContent(path: string) {
|
|
contentLoading.value = true
|
|
selectedDoc.value = null
|
|
try {
|
|
const response = await apiFetch(`/api/v1/docs/${encodeURIComponent(path)}`)
|
|
if (!response.ok) throw new Error('Failed to load document content')
|
|
selectedDoc.value = await response.json()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load document content'
|
|
} finally {
|
|
contentLoading.value = false
|
|
}
|
|
}
|
|
|
|
function selectDoc(name: string, path: string) {
|
|
loadDocContent(path)
|
|
}
|
|
|
|
function goBack() {
|
|
selectedDoc.value = null
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
onMounted(loadDocs)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-heading">
|
|
<div>
|
|
<span class="eyebrow">KNOWLEDGE</span>
|
|
<h1>Docs</h1>
|
|
<p>Browse documentation, agent skills, and workspace references.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="docs-toolbar">
|
|
<div class="docs-search-bar">
|
|
<Search :size="16" />
|
|
<input
|
|
v-model="searchQuery"
|
|
placeholder="Search documents..."
|
|
/>
|
|
</div>
|
|
<div class="docs-filter-group">
|
|
<Filter :size="14" />
|
|
<select v-model="categoryFilter">
|
|
<option value="">All categories</option>
|
|
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="memory-layout">
|
|
<!-- Left column: document list -->
|
|
<aside class="memory-sidebar">
|
|
<div v-if="loading" class="memory-status">
|
|
<Loader2 :size="16" class="spin" />
|
|
Loading documents...
|
|
</div>
|
|
<div v-else-if="error" class="memory-status error">{{ error }}</div>
|
|
<template v-else-if="filteredDocs.length">
|
|
<div class="memory-list-header">{{ filteredDocs.length }} documents</div>
|
|
<button
|
|
v-for="doc in filteredDocs"
|
|
:key="doc.name + doc.path"
|
|
:class="['memory-file-item', { active: selectedDoc?.name === doc.name && selectedDoc?.path === doc.path }]"
|
|
@click="selectDoc(doc.name, doc.path)"
|
|
>
|
|
<div class="memory-file-icon">
|
|
<FileText :size="14" />
|
|
</div>
|
|
<div class="memory-file-info">
|
|
<strong>{{ doc.name }}</strong>
|
|
<div class="doc-tags">
|
|
<span :class="['doc-category-badge', `cat-${doc.category}`]">{{ doc.category }}</span>
|
|
<span class="doc-type-tag">{{ doc.type }}</span>
|
|
</div>
|
|
<span class="memory-file-meta">
|
|
<Clock :size="10" /> {{ formatDate(doc.modifiedAt) }}
|
|
· {{ formatSize(doc.size) }}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</template>
|
|
<div v-else class="memory-status">No documents match your filters</div>
|
|
</aside>
|
|
|
|
<!-- Right column: content -->
|
|
<main class="memory-content">
|
|
<template v-if="contentLoading">
|
|
<div class="memory-status">
|
|
<Loader2 :size="20" class="spin" />
|
|
Loading content...
|
|
</div>
|
|
</template>
|
|
<template v-else-if="selectedDoc">
|
|
<header class="memory-content-header">
|
|
<button class="memory-back-btn" @click="goBack">
|
|
<ArrowLeft :size="14" />
|
|
Back
|
|
</button>
|
|
<div>
|
|
<strong>{{ selectedDoc.name }}</strong>
|
|
<div class="doc-tags">
|
|
<span :class="['doc-category-badge', `cat-${selectedDoc.category}`]">{{ selectedDoc.category }}</span>
|
|
<span class="doc-type-tag">{{ selectedDoc.type }}</span>
|
|
</div>
|
|
<span class="memory-content-meta">
|
|
<Clock :size="10" /> {{ formatDate(selectedDoc.modifiedAt) }}
|
|
· {{ formatSize(selectedDoc.size) }}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
<article
|
|
class="memory-rendered"
|
|
v-html="renderMarkdown(selectedDoc.content)"
|
|
></article>
|
|
</template>
|
|
<template v-else>
|
|
<div class="memory-empty-state">
|
|
<BookOpen :size="28" />
|
|
<h3>Select a document</h3>
|
|
<p>Choose a document from the list or use the search/filter to find what you need.</p>
|
|
</div>
|
|
</template>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.docs-toolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.docs-search-bar {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
color: #6f7889;
|
|
}
|
|
.docs-search-bar input {
|
|
flex: 1;
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
color: #e8eaf0;
|
|
font: inherit;
|
|
font-size: 13px;
|
|
}
|
|
.docs-filter-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
color: #8991a1;
|
|
}
|
|
.docs-filter-group select {
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
color: #e8eaf0;
|
|
font: inherit;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.memory-layout {
|
|
display: grid;
|
|
grid-template-columns: 320px 1fr;
|
|
gap: 12px;
|
|
min-height: 480px;
|
|
}
|
|
.memory-sidebar {
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
background: var(--panel);
|
|
padding: 8px;
|
|
max-height: 640px;
|
|
overflow-y: auto;
|
|
}
|
|
.memory-list-header {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
color: #7065c8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.14em;
|
|
padding: 10px 8px 6px;
|
|
}
|
|
.memory-file-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: none;
|
|
border-radius: 7px;
|
|
background: transparent;
|
|
color: #e8eaf0;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
}
|
|
.memory-file-item:hover,
|
|
.memory-file-item.active {
|
|
background: var(--accent-soft);
|
|
}
|
|
.memory-file-icon {
|
|
flex-shrink: 0;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 6px;
|
|
color: #a99cf5;
|
|
background: rgba(139,124,246,.1);
|
|
}
|
|
.memory-file-info strong {
|
|
display: block;
|
|
font-size: 11px;
|
|
margin-bottom: 3px;
|
|
word-break: break-word;
|
|
}
|
|
.doc-tags {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 3px;
|
|
}
|
|
.doc-category-badge {
|
|
font-size: 8px;
|
|
padding: 1px 6px;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.doc-category-badge.cat-phases {
|
|
background: rgba(139,124,246,.12);
|
|
color: #a99cf5;
|
|
}
|
|
.doc-category-badge.cat-skills {
|
|
background: rgba(81,212,154,.1);
|
|
color: #51d49a;
|
|
}
|
|
.doc-category-badge.cat-workspace {
|
|
background: rgba(229,176,94,.1);
|
|
color: #e5b05e;
|
|
}
|
|
.doc-category-badge.cat-nexus {
|
|
background: rgba(109,159,230,.1);
|
|
color: #6d9fe6;
|
|
}
|
|
.doc-category-badge.cat-nexus-phases {
|
|
background: rgba(225,110,117,.1);
|
|
color: #e16e75;
|
|
}
|
|
.doc-type-tag {
|
|
font-size: 8px;
|
|
padding: 1px 5px;
|
|
border: 1px solid #343947;
|
|
border-radius: 4px;
|
|
color: #8991a1;
|
|
}
|
|
.memory-file-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 9px;
|
|
color: #7e8799;
|
|
}
|
|
.memory-content {
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
background: var(--panel);
|
|
padding: 24px;
|
|
min-height: 480px;
|
|
overflow-y: auto;
|
|
}
|
|
.memory-content-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.memory-content-header > div strong {
|
|
display: block;
|
|
font-size: 13px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.memory-content-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 9px;
|
|
color: #7e8799;
|
|
margin-top: 4px;
|
|
}
|
|
.memory-back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 5px 10px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: #8991a1;
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.memory-back-btn:hover {
|
|
color: #e8eaf0;
|
|
background: var(--accent-soft);
|
|
}
|
|
.memory-status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 32px;
|
|
color: #7e8799;
|
|
font-size: 11px;
|
|
}
|
|
.memory-status.error {
|
|
color: #e16e75;
|
|
}
|
|
.memory-empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
min-height: 360px;
|
|
color: #6b7385;
|
|
}
|
|
.memory-empty-state h3 {
|
|
margin: 12px 0 6px;
|
|
font-size: 14px;
|
|
color: #a5adba;
|
|
}
|
|
.memory-empty-state p {
|
|
margin: 0;
|
|
font-size: 10px;
|
|
color: #7e8799;
|
|
}
|
|
.memory-rendered {
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
color: #d0d4dd;
|
|
}
|
|
.memory-rendered :deep(h1),
|
|
.memory-rendered :deep(h2),
|
|
.memory-rendered :deep(h3) {
|
|
color: #e8eaf0;
|
|
margin: 1.2em 0 0.5em;
|
|
}
|
|
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
|
.memory-rendered :deep(h2) { font-size: 1.1rem; }
|
|
.memory-rendered :deep(h3) { font-size: 1rem; }
|
|
.memory-rendered :deep(p) { margin: 0.6em 0; }
|
|
.memory-rendered :deep(code) {
|
|
padding: 2px 5px;
|
|
background: rgba(139,124,246,.08);
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
}
|
|
.memory-rendered :deep(pre) {
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
background: #0d1016;
|
|
border: 1px solid var(--line);
|
|
overflow-x: auto;
|
|
}
|
|
.memory-rendered :deep(pre code) {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
.memory-rendered :deep(a) {
|
|
color: #a99cf5;
|
|
text-decoration: none;
|
|
}
|
|
.memory-rendered :deep(a:hover) {
|
|
text-decoration: underline;
|
|
}
|
|
.memory-rendered :deep(ul) {
|
|
padding-left: 20px;
|
|
}
|
|
.memory-rendered :deep(li) {
|
|
margin: 0.3em 0;
|
|
}
|
|
.memory-rendered :deep(hr) {
|
|
border: none;
|
|
border-top: 1px solid var(--line);
|
|
margin: 1.2em 0;
|
|
}
|
|
.memory-rendered :deep(strong) {
|
|
color: #e8eaf0;
|
|
}
|
|
.spin {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.memory-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.memory-sidebar {
|
|
max-height: 280px;
|
|
}
|
|
}
|
|
</style>
|