chore: initial commit — existing single-user document scanner codebase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-22 08:53:28 +02:00
parent 6fed5ba531
commit 7a34807fa0
71 changed files with 16408 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document Scanner</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "document-scanner-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="flex h-screen overflow-hidden">
<AppSidebar />
<main class="flex-1 overflow-y-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import AppSidebar from './components/layout/AppSidebar.vue'
import { useTopicsStore } from './stores/topics.js'
import { onMounted } from 'vue'
const topicsStore = useTopicsStore()
onMounted(() => topicsStore.fetchTopics())
</script>
+105
View File
@@ -0,0 +1,105 @@
/**
* API client using native Fetch API.
* All requests go to /api (proxied to backend by Vite in dev, or nginx in prod).
*/
async function request(path, options = {}) {
const res = await fetch(path, options)
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { msg = (await res.json()).detail || msg } catch {}
throw new Error(msg)
}
return res.json()
}
// ── Documents ────────────────────────────────────────────────────────────────
export function uploadDocument(file, autoClassify = true) {
const form = new FormData()
form.append('file', file)
form.append('auto_classify', autoClassify ? 'true' : 'false')
return request('/api/documents/upload', { method: 'POST', body: form })
}
export function listDocuments({ topic, page = 1, perPage = 20 } = {}) {
const params = new URLSearchParams({ page, per_page: perPage })
if (topic) params.set('topic', topic)
return request(`/api/documents?${params}`)
}
export function getDocument(id) {
return request(`/api/documents/${id}`)
}
export function deleteDocument(id) {
return request(`/api/documents/${id}`, { method: 'DELETE' })
}
export function classifyDocument(id, topics = null) {
return request(`/api/documents/${id}/classify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(topics ? { topics } : {}),
})
}
// ── Topics ───────────────────────────────────────────────────────────────────
export function listTopics() {
return request('/api/topics')
}
export function createTopic({ name, description = '', color = '#6366f1' }) {
return request('/api/topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, color }),
})
}
export function updateTopic(id, patch) {
return request(`/api/topics/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
export function deleteTopic(id) {
return request(`/api/topics/${id}`, { method: 'DELETE' })
}
export function suggestTopics(documentId) {
return request('/api/topics/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: documentId }),
})
}
// ── Settings ─────────────────────────────────────────────────────────────────
export function getSettings() {
return request('/api/settings')
}
export function patchSettings(patch) {
return request('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
export function testProvider(provider) {
return request('/api/settings/test-provider', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
}
export function getDefaultPrompt() {
return request('/api/settings/default-prompt')
}
@@ -0,0 +1,59 @@
<template>
<div
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer"
@click="$router.push(`/document/${doc.id}`)"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-5 h-5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 text-sm truncate">{{ doc.original_name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }}</p>
<!-- Topics -->
<div class="flex flex-wrap gap-1 mt-2">
<TopicBadge
v-for="topicName in doc.topics"
:key="topicName"
:name="topicName"
:color="topicColor(topicName)"
/>
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useTopicsStore } from '../../stores/topics.js'
import TopicBadge from '../topics/TopicBadge.vue'
const props = defineProps({
doc: Object,
})
const topicsStore = useTopicsStore()
function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
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,87 @@
<template>
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
<!-- Logo -->
<div class="px-6 py-5 border-b border-gray-100">
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocScanner</h1>
<p class="text-xs text-gray-400 mt-0.5">AI Document Classifier</p>
</div>
<!-- Nav -->
<nav class="flex-1 px-3 py-4 overflow-y-auto">
<router-link
to="/"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/' }"
>
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</router-link>
<router-link
to="/topics"
class="nav-link"
:class="{ 'nav-link-active': $route.path.startsWith('/topics') }"
>
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
All Topics
</router-link>
<!-- Topics list -->
<div class="mt-3">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p>
<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading</div>
<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>
<router-link
v-for="topic in topicsStore.topics"
:key="topic.id"
:to="`/topics/${encodeURIComponent(topic.name)}`"
class="nav-link text-sm"
:class="{ 'nav-link-active': $route.params.name === topic.name }"
>
<span
class="w-2.5 h-2.5 rounded-full mr-2 shrink-0"
:style="{ backgroundColor: topic.color }"
></span>
<span class="truncate">{{ topic.name }}</span>
<span class="ml-auto text-xs text-gray-400">{{ topic.doc_count }}</span>
</router-link>
</div>
</nav>
<!-- Settings link -->
<div class="px-3 py-4 border-t border-gray-100">
<router-link
to="/settings"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/settings' }"
>
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</router-link>
</div>
</aside>
</template>
<script setup>
import { useTopicsStore } from '../../stores/topics.js'
const topicsStore = useTopicsStore()
</script>
<style scoped>
.nav-link {
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium;
}
.nav-link-active {
@apply bg-indigo-50 text-indigo-700;
}
</style>
@@ -0,0 +1,15 @@
<template>
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:style="{ backgroundColor: color + '22', color }"
>
{{ name }}
</span>
</template>
<script setup>
defineProps({
name: String,
color: { type: String, default: '#6366f1' },
})
</script>
@@ -0,0 +1,124 @@
<template>
<div>
<!-- Add form -->
<form @submit.prevent="submit" class="flex gap-2 mb-6">
<input
v-model="form.name"
type="text"
placeholder="New topic name…"
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
required
/>
<input
v-model="form.color"
type="color"
class="w-10 h-10 rounded-lg border border-gray-300 cursor-pointer p-0.5"
title="Pick color"
/>
<button
type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
:disabled="saving"
>
{{ saving ? 'Adding…' : 'Add' }}
</button>
</form>
<!-- Error -->
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p>
<!-- Topic list -->
<div class="space-y-2">
<div
v-for="topic in topicsStore.topics"
:key="topic.id"
class="flex items-center gap-3 bg-white border border-gray-200 rounded-lg px-4 py-3"
>
<span
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: topic.color }"
></span>
<div v-if="editing === topic.id" class="flex-1 flex gap-2">
<input
v-model="editForm.name"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<input v-model="editForm.color" type="color" class="w-8 h-8 rounded border border-gray-300 p-0.5" />
<input
v-model="editForm.description"
placeholder="Description"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button @click="saveEdit(topic.id)" class="text-xs text-indigo-600 font-medium">Save</button>
<button @click="editing = null" class="text-xs text-gray-400">Cancel</button>
</div>
<div v-else class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-800 text-sm">{{ topic.name }}</span>
<span class="text-xs text-gray-400">({{ topic.doc_count }} docs)</span>
</div>
<p v-if="topic.description" class="text-xs text-gray-500 mt-0.5">{{ topic.description }}</p>
</div>
<div class="flex gap-2 shrink-0">
<button @click="startEdit(topic)" class="text-xs text-gray-500 hover:text-indigo-600">Edit</button>
<button @click="remove(topic)" class="text-xs text-gray-500 hover:text-red-500">Delete</button>
</div>
</div>
<div v-if="!topicsStore.topics.length" class="text-center py-8 text-gray-400 text-sm">
No topics yet. Add one above.
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useTopicsStore } from '../../stores/topics.js'
const topicsStore = useTopicsStore()
const saving = ref(false)
const error = ref(null)
const editing = ref(null)
const form = reactive({ name: '', color: '#6366f1' })
const editForm = reactive({ name: '', description: '', color: '' })
async function submit() {
saving.value = true
error.value = null
try {
await topicsStore.addTopic({ name: form.name, color: form.color })
form.name = ''
form.color = '#6366f1'
} catch (e) {
error.value = e.message
} finally {
saving.value = false
}
}
function startEdit(topic) {
editing.value = topic.id
editForm.name = topic.name
editForm.description = topic.description || ''
editForm.color = topic.color
}
async function saveEdit(id) {
await topicsStore.editTopic(id, {
name: editForm.name,
description: editForm.description,
color: editForm.color,
})
editing.value = null
}
async function remove(topic) {
if (!confirm(`Delete topic "${topic.name}"? It will be removed from all documents.`)) return
await topicsStore.removeTopic(topic.id)
}
</script>
@@ -0,0 +1,62 @@
<template>
<div
class="relative border-2 border-dashed rounded-xl p-10 text-center transition-colors"
:class="dragging
? 'border-indigo-400 bg-indigo-50'
: 'border-gray-300 bg-white hover:border-indigo-300 hover:bg-gray-50'"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="triggerInput"
>
<input
ref="inputRef"
type="file"
class="hidden"
multiple
accept=".pdf,.docx,.doc,.txt,.md,.png,.jpg,.jpeg,.tiff,.webp"
@change="onFileChange"
/>
<div class="flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div>
<p class="text-sm font-medium text-gray-700">Drop files here or <span class="text-indigo-600 underline cursor-pointer">browse</span></p>
<p class="text-xs text-gray-400 mt-1">PDF, DOCX, TXT, MD, PNG, JPG supported</p>
</div>
<label class="flex items-center gap-2 mt-2 cursor-pointer" @click.stop>
<input type="checkbox" v-model="autoClassify" class="rounded border-gray-300 text-indigo-600" />
<span class="text-sm text-gray-600">Auto-classify with AI after upload</span>
</label>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['files-selected'])
const dragging = ref(false)
const inputRef = ref(null)
const autoClassify = ref(true)
function triggerInput() {
inputRef.value?.click()
}
function onDrop(e) {
dragging.value = false
const files = Array.from(e.dataTransfer?.files || [])
if (files.length) emit('files-selected', { files, autoClassify: autoClassify.value })
}
function onFileChange(e) {
const files = Array.from(e.target.files || [])
if (files.length) emit('files-selected', { files, autoClassify: autoClassify.value })
e.target.value = ''
}
</script>
@@ -0,0 +1,36 @@
<template>
<div v-if="items.length" class="space-y-2 mt-4">
<div
v-for="item in items"
:key="item.name"
class="flex items-center gap-3 bg-white border border-gray-200 rounded-lg px-4 py-2.5"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ item.name }}</p>
<p v-if="item.error" class="text-xs text-red-500 mt-0.5">{{ item.error }}</p>
<p v-else-if="item.done" class="text-xs text-green-600 mt-0.5">
Done{{ item.topics?.length ? ` classified as: ${item.topics.join(', ')}` : ' no topics assigned' }}
</p>
<p v-else class="text-xs text-gray-400 mt-0.5">Uploading</p>
</div>
<div class="shrink-0">
<svg v-if="item.error" class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<svg v-else-if="item.done" class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<svg v-else class="w-5 h-5 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
items: { type: Array, default: () => [] },
})
</script>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import TopicsView from '../views/TopicsView.vue'
import DocumentView from '../views/DocumentView.vue'
import SettingsView from '../views/SettingsView.vue'
const routes = [
{ path: '/', component: HomeView },
{ path: '/topics', component: TopicsView },
{ path: '/topics/:name', component: TopicsView },
{ path: '/document/:id', component: DocumentView },
{ path: '/settings', component: SettingsView },
]
export default createRouter({
history: createWebHistory(),
routes,
})
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '../api/client.js'
export const useDocumentsStore = defineStore('documents', () => {
const documents = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)
async function fetchDocuments({ topic, page = 1, perPage = 20 } = {}) {
loading.value = true
error.value = null
try {
const data = await api.listDocuments({ topic, page, perPage })
documents.value = data.items
total.value = data.total
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function upload(file, autoClassify = true) {
const doc = await api.uploadDocument(file, autoClassify)
documents.value.unshift(doc)
total.value++
return doc
}
async function remove(id) {
await api.deleteDocument(id)
documents.value = documents.value.filter(d => d.id !== id)
total.value--
}
async function reclassify(id, topics = null) {
const result = await api.classifyDocument(id, topics)
const idx = documents.value.findIndex(d => d.id === id)
if (idx !== -1) documents.value[idx].topics = result.topics
return result.topics
}
return { documents, total, loading, error, fetchDocuments, upload, remove, reclassify }
})
+38
View File
@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '../api/client.js'
export const useSettingsStore = defineStore('settings', () => {
const settings = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchSettings() {
loading.value = true
error.value = null
try {
settings.value = await api.getSettings()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function save(patch) {
const updated = await api.patchSettings(patch)
settings.value = updated
return updated
}
async function testConnection(provider) {
return api.testProvider(provider)
}
async function resetPrompt() {
const data = await api.getDefaultPrompt()
return data.system_prompt
}
return { settings, loading, error, fetchSettings, save, testConnection, resetPrompt }
})
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '../api/client.js'
export const useTopicsStore = defineStore('topics', () => {
const topics = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchTopics() {
loading.value = true
error.value = null
try {
const data = await api.listTopics()
topics.value = data.topics
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function addTopic(payload) {
const topic = await api.createTopic(payload)
topics.value.push(topic)
return topic
}
async function editTopic(id, patch) {
const updated = await api.updateTopic(id, patch)
const idx = topics.value.findIndex(t => t.id === id)
if (idx !== -1) topics.value[idx] = updated
return updated
}
async function removeTopic(id) {
await api.deleteTopic(id)
topics.value = topics.value.filter(t => t.id !== id)
}
return { topics, loading, error, fetchTopics, addTopic, editTopic, removeTopic }
})
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900;
}
}
+184
View File
@@ -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>
+63
View File
@@ -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>
+223
View File
@@ -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>
+82
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {},
},
plugins: [],
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})