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,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>
|
||||
Reference in New Issue
Block a user