feat(14.1-03): extract DocumentDetailSurface + refactor DocumentView (Re-analyze copy)
- Create frontend/src/components/storage/DocumentDetailSurface.vue as shared detail surface for both local and cloud files (section order: Back → Header → Status → Topics → Extracted Text → secondary controls) - Props: title, metadataLine, source, topics, analysisStatus, extractedText, previewState, downloadState, analysisAction - Emits: back, preview, download, analyze, reanalyze, retry-analysis - Single analysis action slot swaps Analyze/Re-analyze/Retry by analysisAction.kind (no simultaneous buttons per UI-SPEC Re-Analyze contract) - Imports formatDate, formatSize, providerColor, providerBg, providerLabel from utils/formatters.js (no local redefinitions) - Refactor DocumentView.vue to thin data-provider using DocumentDetailSurface - Visible label changed from "Re-classify" to "Re-analyze" (internal classifyDocument API call preserved per D-09 / Codex discretion) - No rendered Re-classify remains in either file - CloudDetailParity Re-classify regression test now passes
This commit is contained in:
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-8 max-w-4xl mx-auto">
|
||||||
|
<!-- (1) Back navigation slot -->
|
||||||
|
<slot name="back">
|
||||||
|
<button
|
||||||
|
@click="$emit('back')"
|
||||||
|
class="text-sm text-indigo-600 hover:underline mb-6 flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<!-- (2) Header block: title, metadata, source metadata, primary action cluster -->
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<!-- Title — 24px semibold, break-all wrap per UI-SPEC -->
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 break-all">{{ title }}</h2>
|
||||||
|
|
||||||
|
<!-- Metadata line — date/size/type, 12-14px muted -->
|
||||||
|
<p class="text-sm text-gray-400 mt-1">{{ metadataLine }}</p>
|
||||||
|
|
||||||
|
<!-- Cloud source metadata: provider chip + location (subtle, only when source provided) -->
|
||||||
|
<div v-if="source && source.provider" class="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="[providerBg(source.provider), providerColor(source.provider)]"
|
||||||
|
>
|
||||||
|
{{ providerLabel(source.provider) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="source.location" class="text-xs text-gray-400 truncate max-w-xs">
|
||||||
|
{{ source.location }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary action cluster -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||||
|
<!-- Content action: Preview / Open -->
|
||||||
|
<div v-if="previewState">
|
||||||
|
<button
|
||||||
|
v-if="previewState.supported"
|
||||||
|
@click="$emit('preview')"
|
||||||
|
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{{ previewState.openLabel || 'Preview file' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
disabled
|
||||||
|
class="text-sm px-3 py-1.5 bg-gray-100 text-gray-400 rounded-lg cursor-not-allowed"
|
||||||
|
:title="previewState.reason || 'Preview unavailable'"
|
||||||
|
>
|
||||||
|
Preview unavailable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download action (explicit — never auto-triggered by preview) -->
|
||||||
|
<button
|
||||||
|
v-if="downloadState && downloadState.supported"
|
||||||
|
@click="$emit('download')"
|
||||||
|
class="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Single analysis action slot — swaps Analyze / Re-analyze / Retry by state (never both) -->
|
||||||
|
<button
|
||||||
|
v-if="analysisAction && analysisAction.kind === 'analyze'"
|
||||||
|
@click="$emit('analyze')"
|
||||||
|
:disabled="analysisAction.busy"
|
||||||
|
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{{ analysisAction.busy ? 'Analyzing…' : 'Analyze file' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="analysisAction && analysisAction.kind === 'reanalyze'"
|
||||||
|
@click="$emit('reanalyze')"
|
||||||
|
:disabled="analysisAction.busy"
|
||||||
|
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{{ analysisAction.busy ? 'Analyzing…' : 'Re-analyze' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="analysisAction && analysisAction.kind === 'retry'"
|
||||||
|
@click="$emit('retry-analysis')"
|
||||||
|
:disabled="analysisAction.busy"
|
||||||
|
class="text-sm px-3 py-1.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 active:bg-amber-800 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{{ analysisAction.busy ? 'Retrying…' : 'Retry analysis' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Secondary action slot (delete, share, suggest — injected by local DocumentView) -->
|
||||||
|
<slot name="secondary-actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- (3) Status and source notice area -->
|
||||||
|
<div v-if="analysisStatus || (source && source.isStale)" class="mb-5">
|
||||||
|
<!-- Stale badge -->
|
||||||
|
<div
|
||||||
|
v-if="source && source.isStale"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800 mb-2"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Stale</span>
|
||||||
|
<span class="text-amber-700">Analysis data may be outdated. Re-analyze to refresh.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview unavailable notice (shown below header when preview is not supported) -->
|
||||||
|
<div
|
||||||
|
v-if="previewState && !previewState.supported && previewState.reason"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600 mb-2"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Preview unavailable</span>
|
||||||
|
<span>{{ previewState.reason }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Working status -->
|
||||||
|
<div
|
||||||
|
v-if="isWorking"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ source && source.statusLabel ? source.statusLabel : 'Working…' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cloud source notice (subtle — for cloud files only) -->
|
||||||
|
<div
|
||||||
|
v-if="source && source.provider"
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
>
|
||||||
|
Preview file, download, and analysis still run through DocuVault.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- (4) Topics section -->
|
||||||
|
<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="text-sm font-semibold text-gray-800">Topics</h3>
|
||||||
|
<!-- Suggest topics slot (local DocumentView injects this) -->
|
||||||
|
<slot name="topics-actions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<TopicBadge
|
||||||
|
v-for="topic in normalizedTopics"
|
||||||
|
:key="topic.name"
|
||||||
|
:name="topic.name"
|
||||||
|
:color="topic.color"
|
||||||
|
/>
|
||||||
|
<span v-if="!normalizedTopics.length" class="text-sm text-gray-400 italic">No topics assigned yet.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline error (e.g. classify error) -->
|
||||||
|
<slot name="topics-error" />
|
||||||
|
|
||||||
|
<!-- Suggestions panel (local DocumentView injects this) -->
|
||||||
|
<slot name="suggestions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- (5) Extracted text section -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 mb-3">Extracted Text</h3>
|
||||||
|
<div v-if="!analysisStatus || analysisStatus === 'pending' || analysisStatus === 'none'" class="text-sm text-gray-400 italic">
|
||||||
|
<!-- Empty state per Copywriting Contract -->
|
||||||
|
<p class="font-medium text-gray-600">No analysis yet</p>
|
||||||
|
<p class="mt-1">Analyze this file to extract text and topics.</p>
|
||||||
|
</div>
|
||||||
|
<pre v-else class="text-xs text-gray-600 whitespace-pre-wrap font-mono bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">{{ extractedText || '(no text extracted)' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- (6) Secondary controls / inline error+help copy -->
|
||||||
|
<slot name="secondary-controls" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import TopicBadge from '../topics/TopicBadge.vue'
|
||||||
|
import { formatDate, formatSize, providerColor, providerBg, providerLabel } from '../../utils/formatters.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Document title / filename */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
/** Formatted metadata string: date · size · type */
|
||||||
|
metadataLine: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Cloud source metadata (omit or null for local files)
|
||||||
|
* { provider, location, isStale, statusLabel }
|
||||||
|
*/
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Topics array — either strings or {name, color} objects.
|
||||||
|
* DocumentView passes {name, color} objects; CloudDetailView passes strings.
|
||||||
|
*/
|
||||||
|
topics: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
/** Internal analysis status string (e.g. 'indexed', 'pending', 'failed', 'stale') */
|
||||||
|
analysisStatus: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/** Extracted text content */
|
||||||
|
extractedText: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Preview capability state
|
||||||
|
* { supported: boolean, reason?: string, openLabel?: string }
|
||||||
|
*/
|
||||||
|
previewState: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Download capability state
|
||||||
|
* { supported: boolean }
|
||||||
|
*/
|
||||||
|
downloadState: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Analysis action to show (single — swaps by state)
|
||||||
|
* { kind: 'analyze' | 'reanalyze' | 'retry', busy: boolean }
|
||||||
|
*/
|
||||||
|
analysisAction: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['back', 'preview', 'download', 'analyze', 'reanalyze', 'retry-analysis'])
|
||||||
|
|
||||||
|
/** Normalize topics to always be {name, color} objects */
|
||||||
|
const normalizedTopics = computed(() => {
|
||||||
|
return (props.topics || []).map(t => {
|
||||||
|
if (typeof t === 'string') return { name: t, color: '#6366f1' }
|
||||||
|
return { name: t.name ?? t, color: t.color ?? '#6366f1' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Show working state badge when analysis is in progress */
|
||||||
|
const isWorking = computed(() => {
|
||||||
|
const workingStatuses = new Set(['queued', 'downloading', 'extracting', 'classifying'])
|
||||||
|
return workingStatuses.has(props.analysisStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-export formatters so template can use them (tree-shaken if unused)
|
||||||
|
defineExpose({ formatDate, formatSize, providerColor, providerBg, providerLabel })
|
||||||
|
</script>
|
||||||
@@ -1,73 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 max-w-4xl mx-auto">
|
<div>
|
||||||
<!-- Back -->
|
<div v-if="loading" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Loading…</div>
|
||||||
<button @click="$router.back()" class="text-sm text-indigo-600 hover:underline mb-6 flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
<div v-else-if="!doc" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Document not found.</div>
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-400 text-sm">Loading…</div>
|
<DocumentDetailSurface
|
||||||
<div v-else-if="!doc" class="text-gray-400 text-sm">Document not found.</div>
|
v-else
|
||||||
|
:title="doc.original_name"
|
||||||
|
:metadata-line="metadataLine"
|
||||||
|
:source="null"
|
||||||
|
:topics="doc.topics || []"
|
||||||
|
:analysis-status="doc.analysis_status || 'indexed'"
|
||||||
|
:extracted-text="doc.extracted_text"
|
||||||
|
:preview-state="previewStateComputed"
|
||||||
|
:download-state="null"
|
||||||
|
:analysis-action="analysisActionComputed"
|
||||||
|
@back="$router.back()"
|
||||||
|
@preview="openPdf"
|
||||||
|
@reanalyze="reclassify"
|
||||||
|
@retry-analysis="reclassify"
|
||||||
|
>
|
||||||
|
<template #secondary-actions>
|
||||||
|
<button
|
||||||
|
@click="confirmDelete"
|
||||||
|
class="text-sm text-red-500 hover:text-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
|
||||||
|
>Delete</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template #topics-actions>
|
||||||
<!-- Header -->
|
<div class="flex gap-2">
|
||||||
<div class="flex items-start justify-between gap-4 mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold 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
|
<button
|
||||||
v-if="isPdf"
|
@click="suggestTopics"
|
||||||
@click="openPdf"
|
:disabled="suggesting"
|
||||||
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
class="text-xs px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
{{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
|
{{ suggesting ? 'Suggesting…' : 'Suggest Topics' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@click="confirmDelete"
|
|
||||||
class="text-sm text-red-500 hover:text-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
|
|
||||||
>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="text-sm 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 active:bg-indigo-800 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
{{ 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 active:bg-gray-100 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
{{ 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>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #topics-error>
|
||||||
<p v-if="classifyError" class="text-red-500 text-xs mt-2">{{ classifyError }}</p>
|
<p v-if="classifyError" class="text-red-500 text-xs mt-2">{{ classifyError }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Suggestions modal inline -->
|
<template #suggestions>
|
||||||
|
<!-- Suggestions panel inline -->
|
||||||
<div v-if="suggestions.length" class="mt-4 border-t border-gray-100 pt-4">
|
<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>
|
<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">
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
@@ -93,14 +69,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</DocumentDetailSurface>
|
||||||
<!-- Extracted text -->
|
|
||||||
<div class="bg-white border border-gray-200 rounded-xl p-5">
|
|
||||||
<h3 class="text-sm 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 -->
|
<!-- PDF in-app preview modal -->
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
@@ -149,7 +119,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { formatDate, formatSize } from '../utils/formatters.js'
|
import { formatDate, formatSize } from '../utils/formatters.js'
|
||||||
import AppIcon from '../components/ui/AppIcon.vue'
|
import AppIcon from '../components/ui/AppIcon.vue'
|
||||||
import TopicBadge from '../components/topics/TopicBadge.vue'
|
import DocumentDetailSurface from '../components/storage/DocumentDetailSurface.vue'
|
||||||
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
|
||||||
import { useDocumentsStore } from '../stores/documents.js'
|
import { useDocumentsStore } from '../stores/documents.js'
|
||||||
import { useTopicsStore } from '../stores/topics.js'
|
import { useTopicsStore } from '../stores/topics.js'
|
||||||
@@ -180,6 +150,33 @@ const isPdf = computed(() => {
|
|||||||
return mime === 'application/pdf' || name.toLowerCase().endsWith('.pdf')
|
return mime === 'application/pdf' || name.toLowerCase().endsWith('.pdf')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Metadata line: date · size · MIME type */
|
||||||
|
const metadataLine = computed(() => {
|
||||||
|
if (!doc.value) return ''
|
||||||
|
const parts = []
|
||||||
|
if (doc.value.created_at) parts.push(`Uploaded ${formatDate(doc.value.created_at)}`)
|
||||||
|
if (doc.value.size_bytes) parts.push(formatSize(doc.value.size_bytes))
|
||||||
|
if (doc.value.mime_type) parts.push(doc.value.mime_type)
|
||||||
|
return parts.join(' · ')
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Preview state for the shared surface */
|
||||||
|
const previewStateComputed = computed(() => {
|
||||||
|
if (!isPdf.value) return null
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
openLabel: pdfOpenMode.value === 'in_app' ? 'Preview' : 'Open',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Analysis action for the shared surface — local documents use Re-analyze (label) */
|
||||||
|
const analysisActionComputed = computed(() => {
|
||||||
|
return {
|
||||||
|
kind: 'reanalyze',
|
||||||
|
busy: classifying.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
doc.value = await api.getDocument(route.params.id)
|
doc.value = await api.getDocument(route.params.id)
|
||||||
@@ -225,10 +222,7 @@ async function openPdf() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicColor(name) {
|
/** Re-analyze (internal: classifyDocument API — preserving D-09 / Codex discretion) */
|
||||||
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reclassify() {
|
async function reclassify() {
|
||||||
classifying.value = true
|
classifying.value = true
|
||||||
classifyError.value = null
|
classifyError.value = null
|
||||||
|
|||||||
Reference in New Issue
Block a user