Files
kite/frontend/src/views/DocumentView.vue
T
curo1305andClaude Sonnet 4.6 cce70b2ef6 refactor(frontend): extract shared modules, thin views, delete dead code
Shared utilities:
- Add src/utils/formatters.js — formatDate, formatSize, providerColor,
  providerBg, providerLabel; all components import from here, no inline duplicates
- Add src/components/ui/TreeItem.vue — generic expand/collapse tree node;
  FolderTreeItem, CloudFolderTreeItem, CloudProviderTreeItem now wrap it
- Add src/components/storage/StorageBrowser.vue — unified file browser grid
  used by both FileManagerView and CloudFolderView

View refactor (thin data-providers):
- FileManagerView.vue: stripped to props + event wiring; all layout moved to StorageBrowser
- CloudFolderView.vue: same treatment — feeds props into StorageBrowser
- All tree sidebar components delegate expand/collapse to TreeItem.vue

Dead code removed:
- Delete HomeView.vue — no active route, replaced by FileManagerView
- Delete FolderView.vue — no active route, logic merged into FileManagerView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:10:47 +02:00

297 lines
10 KiB
Vue

<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>
<div class="flex items-center gap-2 shrink-0">
<!-- Open/Preview button for PDFs -->
<button
v-if="isPdf"
@click="openPdf"
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
{{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
</button>
<button
@click="confirmDelete"
class="text-sm text-red-500 hover:text-red-700"
>Delete</button>
</div>
</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>
<!-- PDF in-app preview modal -->
<DocumentPreviewModal
v-if="showPreviewModal && doc"
:doc="doc"
@close="showPreviewModal = false"
/>
<!-- Cloud delete warning modal -->
<div
v-if="showCloudDeleteWarning"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@click.self="cancelCloudDeleteWarning"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="cloud-delete-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full mx-4"
>
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-amber-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h2 id="cloud-delete-modal-title" class="text-lg font-semibold text-gray-900">Cloud delete failed</h2>
</div>
<p class="text-sm text-gray-600 mb-4">
The file could not be deleted from {{ cloudProviderName }}. Remove it from DocuVault anyway? The file will remain on {{ cloudProviderName }}.
</p>
<div class="flex gap-2 justify-end">
<button
@click="cancelCloudDeleteWarning"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>Cancel</button>
<button
@click="confirmRemoveOnly"
class="bg-red-600 hover:bg-red-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>Remove from app</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatDate, formatSize } from '../utils/formatters.js'
import TopicBadge from '../components/topics/TopicBadge.vue'
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js'
import * as api from '../api/client.js'
import { fetchDocumentContent } 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([])
const showPreviewModal = ref(false)
const pdfOpenMode = ref('new_tab')
const showCloudDeleteWarning = ref(false)
const cloudProviderName = ref('your cloud storage')
const isPdf = computed(() => {
if (!doc.value) return false
const mime = doc.value.mime_type || ''
const name = doc.value.original_name || ''
return mime === 'application/pdf' || name.toLowerCase().endsWith('.pdf')
})
onMounted(async () => {
try {
doc.value = await api.getDocument(route.params.id)
} finally {
loading.value = false
}
// Load user preferences for PDF open mode
try {
const prefs = await api.getMyPreferences()
pdfOpenMode.value = prefs.pdf_open_mode || 'new_tab'
} catch {
pdfOpenMode.value = 'new_tab'
}
})
async function openPdf() {
if (pdfOpenMode.value === 'in_app') {
showPreviewModal.value = true
} else {
// Fetch with Authorization header → blob → object URL → window.open
// This closes the unauthenticated access gap: window.open(rawUrl) would bypass
// Bearer auth for cloud documents (plan 05-09 trust boundary).
try {
const res = await fetchDocumentContent(doc.value.id)
if (!res.ok) {
console.error('Failed to open document:', res.status)
return
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const tab = window.open(objectUrl, '_blank')
// Revoke once the new tab has loaded, or after 120s as a fallback.
// Also revoke if the user navigates away from this page before the tab loads.
const revoke = () => URL.revokeObjectURL(objectUrl)
const timer = setTimeout(revoke, 120000)
window.addEventListener('pagehide', revoke, { once: true })
if (tab) {
tab.addEventListener('load', () => { clearTimeout(timer); revoke() }, { once: true })
}
} catch (err) {
console.error('Failed to open document:', err)
}
}
}
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()
}
const _PROVIDER_NAMES = { google_drive: 'Google Drive', onedrive: 'OneDrive', nextcloud: 'Nextcloud', webdav: 'WebDAV' }
async function confirmDelete() {
if (!confirm(`Delete "${doc.value.original_name}"?`)) return
const resp = await api.deleteDocument(doc.value.id)
if (resp && resp.cloud_delete_failed) {
cloudProviderName.value = _PROVIDER_NAMES[doc.value.storage_backend] || 'your cloud storage'
showCloudDeleteWarning.value = true
return
}
router.push('/')
}
async function confirmRemoveOnly() {
try {
await api.deleteDocumentRemoveOnly(doc.value.id)
showCloudDeleteWarning.value = false
router.push('/')
} catch (e) {
// error shown inline if needed — modal stays open
}
}
function cancelCloudDeleteWarning() {
showCloudDeleteWarning.value = false
}
</script>