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
co-authored by Claude Sonnet 4.6
parent 6fed5ba531
commit 7a34807fa0
71 changed files with 16408 additions and 0 deletions
+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>