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:
curo1305
2026-06-26 22:08:40 +02:00
parent 08f6ce4833
commit 825a7b5c84
2 changed files with 330 additions and 73 deletions
@@ -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>
+67 -73
View File
@@ -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