chore: initial commit — existing single-user document scanner codebase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto">
|
||||
<!-- Back -->
|
||||
<button @click="$router.back()" class="text-sm text-indigo-600 hover:underline mb-6 flex items-center gap-1">
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<div v-if="loading" class="text-gray-400 text-sm">Loading…</div>
|
||||
<div v-else-if="!doc" class="text-gray-400 text-sm">Document not found.</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 break-all">{{ doc.original_name }}</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
class="text-sm text-red-500 hover:text-red-700 shrink-0"
|
||||
>Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- Topics -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-gray-800">Topics</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="reclassify"
|
||||
:disabled="classifying"
|
||||
class="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ classifying ? 'Classifying…' : 'Re-classify' }}
|
||||
</button>
|
||||
<button
|
||||
@click="suggestTopics"
|
||||
:disabled="suggesting"
|
||||
class="text-xs px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ suggesting ? 'Suggesting…' : 'Suggest Topics' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<TopicBadge
|
||||
v-for="name in doc.topics"
|
||||
:key="name"
|
||||
:name="name"
|
||||
:color="topicColor(name)"
|
||||
/>
|
||||
<span v-if="!doc.topics?.length" class="text-sm text-gray-400 italic">No topics assigned yet.</span>
|
||||
</div>
|
||||
|
||||
<p v-if="classifyError" class="text-red-500 text-xs mt-2">{{ classifyError }}</p>
|
||||
|
||||
<!-- Suggestions modal inline -->
|
||||
<div v-if="suggestions.length" class="mt-4 border-t border-gray-100 pt-4">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">AI Suggestions — select to create:</p>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<label
|
||||
v-for="s in suggestions"
|
||||
:key="s"
|
||||
class="flex items-center gap-1.5 cursor-pointer text-sm"
|
||||
>
|
||||
<input type="checkbox" v-model="selectedSuggestions" :value="s" class="rounded border-gray-300 text-indigo-600" />
|
||||
{{ s }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="createSelectedTopics"
|
||||
:disabled="!selectedSuggestions.length"
|
||||
class="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Create Selected
|
||||
</button>
|
||||
<button @click="suggestions = []; selectedSuggestions = []" class="text-xs text-gray-500 hover:text-gray-700">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extracted text -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="font-semibold text-gray-800 mb-3">Extracted Text</h3>
|
||||
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">{{ doc.extracted_text || '(no text extracted)' }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import TopicBadge from '../components/topics/TopicBadge.vue'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
import * as api from '../api/client.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const docsStore = useDocumentsStore()
|
||||
const topicsStore = useTopicsStore()
|
||||
|
||||
const doc = ref(null)
|
||||
const loading = ref(true)
|
||||
const classifying = ref(false)
|
||||
const suggesting = ref(false)
|
||||
const classifyError = ref(null)
|
||||
const suggestions = ref([])
|
||||
const selectedSuggestions = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
doc.value = await api.getDocument(route.params.id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function topicColor(name) {
|
||||
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
|
||||
}
|
||||
|
||||
async function reclassify() {
|
||||
classifying.value = true
|
||||
classifyError.value = null
|
||||
try {
|
||||
const result = await api.classifyDocument(doc.value.id)
|
||||
doc.value.topics = result.topics
|
||||
await topicsStore.fetchTopics()
|
||||
} catch (e) {
|
||||
classifyError.value = e.message
|
||||
} finally {
|
||||
classifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function suggestTopics() {
|
||||
suggesting.value = true
|
||||
try {
|
||||
const result = await api.suggestTopics(doc.value.id)
|
||||
suggestions.value = result.suggested
|
||||
selectedSuggestions.value = []
|
||||
} catch (e) {
|
||||
classifyError.value = e.message
|
||||
} finally {
|
||||
suggesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSelectedTopics() {
|
||||
for (const name of selectedSuggestions.value) {
|
||||
await topicsStore.addTopic({ name })
|
||||
}
|
||||
suggestions.value = []
|
||||
selectedSuggestions.value = []
|
||||
// Re-classify now that topics exist
|
||||
await reclassify()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!confirm(`Delete "${doc.value.original_name}"?`)) return
|
||||
await api.deleteDocument(doc.value.id)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">Upload Documents</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">Drop files to extract text and classify them with AI.</p>
|
||||
|
||||
<DropZone @files-selected="onFilesSelected" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
|
||||
<!-- Recent documents -->
|
||||
<div class="mt-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Recent Documents</h3>
|
||||
<span class="text-sm text-gray-400">{{ docsStore.total }} total</span>
|
||||
</div>
|
||||
|
||||
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading…</div>
|
||||
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
|
||||
<p class="text-sm">No documents yet. Upload one above.</p>
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import DropZone from '../components/upload/DropZone.vue'
|
||||
import UploadProgress from '../components/upload/UploadProgress.vue'
|
||||
import DocumentCard from '../components/documents/DocumentCard.vue'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
|
||||
const docsStore = useDocumentsStore()
|
||||
const topicsStore = useTopicsStore()
|
||||
const uploadQueue = ref([])
|
||||
|
||||
onMounted(() => docsStore.fetchDocuments())
|
||||
|
||||
async function onFilesSelected({ files, autoClassify }) {
|
||||
// Build queue items
|
||||
const items = files.map(f => ({ name: f.name, done: false, error: null, topics: null }))
|
||||
uploadQueue.value = [...items, ...uploadQueue.value]
|
||||
|
||||
for (const [i, file] of files.entries()) {
|
||||
try {
|
||||
const doc = await docsStore.upload(file, autoClassify)
|
||||
const item = uploadQueue.value.find(q => q.name === file.name && !q.done && !q.error)
|
||||
if (item) {
|
||||
item.done = true
|
||||
item.topics = doc.topics
|
||||
}
|
||||
} catch (e) {
|
||||
const item = uploadQueue.value.find(q => q.name === file.name && !q.done && !q.error)
|
||||
if (item) item.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh topics (new ones may have been created)
|
||||
await topicsStore.fetchTopics()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-3xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">Settings</h2>
|
||||
<p class="text-gray-500 text-sm mb-8">Configure AI provider and the system prompt.</p>
|
||||
|
||||
<div v-if="settingsStore.loading" class="text-gray-400 text-sm">Loading…</div>
|
||||
<div v-else-if="!settingsStore.settings" class="text-red-500 text-sm">Failed to load settings.</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- AI Provider -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6 mb-5">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">AI Provider</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<button
|
||||
v-for="prov in providers"
|
||||
:key="prov.id"
|
||||
@click="activeProvider = prov.id"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
|
||||
:class="activeProvider === prov.id
|
||||
? 'bg-indigo-600 text-white border-indigo-600'
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50'"
|
||||
>
|
||||
{{ prov.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic config -->
|
||||
<div v-if="activeProvider === 'anthropic'" class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<input
|
||||
v-model="providerCfg.anthropic.api_key"
|
||||
type="password"
|
||||
placeholder="sk-ant-…"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 mt-3">Model</label>
|
||||
<input
|
||||
v-model="providerCfg.anthropic.model"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI config -->
|
||||
<div v-else-if="activeProvider === 'openai'" class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<input
|
||||
v-model="providerCfg.openai.api_key"
|
||||
type="password"
|
||||
placeholder="sk-…"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 mt-3">Model</label>
|
||||
<input
|
||||
v-model="providerCfg.openai.model"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 mt-3">Base URL (optional)</label>
|
||||
<input
|
||||
v-model="providerCfg.openai.base_url"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ollama config -->
|
||||
<div v-else-if="activeProvider === 'ollama'" class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Base URL</label>
|
||||
<input
|
||||
v-model="providerCfg.ollama.base_url"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 mt-3">Model</label>
|
||||
<input
|
||||
v-model="providerCfg.ollama.model"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Ollama must be started with <code class="bg-gray-100 px-1 rounded">OLLAMA_HOST=0.0.0.0 ollama serve</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- LM Studio config -->
|
||||
<div v-else-if="activeProvider === 'lmstudio'" class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Base URL</label>
|
||||
<input
|
||||
v-model="providerCfg.lmstudio.base_url"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 mt-3">Model</label>
|
||||
<input
|
||||
v-model="providerCfg.lmstudio.model"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
LM Studio server must be bound to <code class="bg-gray-100 px-1 rounded">0.0.0.0</code> in LM Studio settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Test connection -->
|
||||
<div class="flex items-center gap-3 mt-5">
|
||||
<button
|
||||
@click="testConn"
|
||||
:disabled="testing"
|
||||
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ testing ? 'Testing…' : 'Test Connection' }}
|
||||
</button>
|
||||
<span v-if="testResult" :class="testResult.ok ? 'text-green-600' : 'text-red-500'" class="text-sm">
|
||||
{{ testResult.ok ? '✓' : '✗' }} {{ testResult.message }}
|
||||
<span v-if="testResult.ok && testResult.latency_ms" class="text-gray-400">({{ testResult.latency_ms }}ms)</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- System Prompt -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6 mb-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-gray-800">System Prompt</h3>
|
||||
<button @click="resetPrompt" class="text-xs text-indigo-600 hover:underline">Reset to default</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="systemPrompt"
|
||||
rows="8"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-400 resize-y"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="save"
|
||||
:disabled="saving"
|
||||
class="px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="saveMsg" :class="saveError ? 'text-red-500' : 'text-green-600'" class="text-sm">
|
||||
{{ saveMsg }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings.js'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
const saveMsg = ref('')
|
||||
const saveError = ref(false)
|
||||
|
||||
const providers = [
|
||||
{ id: 'lmstudio', label: 'LM Studio' },
|
||||
{ id: 'ollama', label: 'Ollama' },
|
||||
{ id: 'openai', label: 'OpenAI' },
|
||||
{ id: 'anthropic', label: 'Anthropic' },
|
||||
]
|
||||
|
||||
const activeProvider = ref('lmstudio')
|
||||
const systemPrompt = ref('')
|
||||
const providerCfg = reactive({
|
||||
anthropic: { api_key: '', model: 'claude-sonnet-4-6' },
|
||||
openai: { api_key: '', model: 'gpt-4o', base_url: '' },
|
||||
ollama: { base_url: 'http://host.docker.internal:11434', model: 'llama3.2' },
|
||||
lmstudio: { base_url: 'http://host.docker.internal:1234', model: 'gemma-4-e4b-it' },
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
populateForm()
|
||||
})
|
||||
|
||||
function populateForm() {
|
||||
const s = settingsStore.settings
|
||||
if (!s) return
|
||||
activeProvider.value = s.active_provider
|
||||
systemPrompt.value = s.system_prompt
|
||||
for (const [k, v] of Object.entries(s.providers || {})) {
|
||||
if (providerCfg[k]) Object.assign(providerCfg[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
async function testConn() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
testResult.value = await settingsStore.testConnection(activeProvider.value)
|
||||
} catch (e) {
|
||||
testResult.value = { ok: false, message: e.message, latency_ms: 0 }
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPrompt() {
|
||||
systemPrompt.value = await settingsStore.resetPrompt()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saveMsg.value = ''
|
||||
saveError.value = false
|
||||
try {
|
||||
await settingsStore.save({
|
||||
system_prompt: systemPrompt.value,
|
||||
active_provider: activeProvider.value,
|
||||
providers: providerCfg,
|
||||
})
|
||||
saveMsg.value = 'Settings saved.'
|
||||
} catch (e) {
|
||||
saveMsg.value = e.message
|
||||
saveError.value = true
|
||||
} finally {
|
||||
saving.value = false
|
||||
setTimeout(() => saveMsg.value = '', 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
{{ activeTopic ? activeTopic : 'All Topics' }}
|
||||
</h2>
|
||||
<p class="text-gray-500 text-sm mt-0.5">
|
||||
{{ activeTopic ? `Documents classified under "${activeTopic}"` : 'Manage topics and browse documents by topic' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeTopic"
|
||||
@click="$router.push('/topics')"
|
||||
class="text-sm text-indigo-600 hover:underline"
|
||||
>
|
||||
← All Topics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No filter: show topic manager + topic grid -->
|
||||
<template v-if="!activeTopic">
|
||||
<TopicManager />
|
||||
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Browse by Topic</h3>
|
||||
<div v-if="topicsStore.topics.length === 0" class="text-sm text-gray-400">No topics yet.</div>
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<router-link
|
||||
v-for="topic in topicsStore.topics"
|
||||
:key="topic.id"
|
||||
:to="`/topics/${encodeURIComponent(topic.name)}`"
|
||||
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: topic.color }"></span>
|
||||
<span class="font-medium text-gray-800 text-sm">{{ topic.name }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ topic.doc_count }}</p>
|
||||
<p class="text-xs text-gray-400">document{{ topic.doc_count !== 1 ? 's' : '' }}</p>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filtered by topic: document list -->
|
||||
<template v-else>
|
||||
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading…</div>
|
||||
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
|
||||
No documents under this topic yet.
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TopicManager from '../components/topics/TopicManager.vue'
|
||||
import DocumentCard from '../components/documents/DocumentCard.vue'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
|
||||
const route = useRoute()
|
||||
const topicsStore = useTopicsStore()
|
||||
const docsStore = useDocumentsStore()
|
||||
|
||||
const activeTopic = computed(() => route.params.name ? decodeURIComponent(route.params.name) : null)
|
||||
|
||||
function loadDocs() {
|
||||
if (activeTopic.value) {
|
||||
docsStore.fetchDocuments({ topic: activeTopic.value })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDocs)
|
||||
watch(activeTopic, loadDocs)
|
||||
</script>
|
||||
Reference in New Issue
Block a user