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>
|
||||
<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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<div v-if="loading" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Loading…</div>
|
||||
<div v-else-if="!doc" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Document not found.</div>
|
||||
|
||||
<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>
|
||||
<DocumentDetailSurface
|
||||
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>
|
||||
<!-- Header -->
|
||||
<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 -->
|
||||
<template #topics-actions>
|
||||
<div class="flex gap-2">
|
||||
<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 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
@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"
|
||||
>
|
||||
{{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
|
||||
{{ suggesting ? 'Suggesting…' : 'Suggest Topics' }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #topics-error>
|
||||
<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">
|
||||
<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">
|
||||
@@ -93,14 +69,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
</DocumentDetailSurface>
|
||||
|
||||
<!-- PDF in-app preview modal -->
|
||||
<DocumentPreviewModal
|
||||
@@ -149,7 +119,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { formatDate, formatSize } from '../utils/formatters.js'
|
||||
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 { useDocumentsStore } from '../stores/documents.js'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
@@ -180,6 +150,33 @@ const isPdf = computed(() => {
|
||||
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 () => {
|
||||
try {
|
||||
doc.value = await api.getDocument(route.params.id)
|
||||
@@ -225,10 +222,7 @@ async function openPdf() {
|
||||
}
|
||||
}
|
||||
|
||||
function topicColor(name) {
|
||||
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
|
||||
}
|
||||
|
||||
/** Re-analyze (internal: classifyDocument API — preserving D-09 / Codex discretion) */
|
||||
async function reclassify() {
|
||||
classifying.value = true
|
||||
classifyError.value = null
|
||||
|
||||
Reference in New Issue
Block a user