Refactor image processing and add image file serving functionality
- Updated image directory handling to prioritize mounted volume for persistence. - Enhanced logging for directory write tests and fallback mechanisms. - Introduced a new endpoint to serve image files directly from local paths. - Added error handling for file serving, including checks for file existence and readability. - Updated the frontend to include a new ContentView component and corresponding route.
This commit is contained in:
393
frontend/src/templates/ContentViewTemplate.tsx
Normal file
393
frontend/src/templates/ContentViewTemplate.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* ContentViewTemplate - Template for displaying individual content with all metadata
|
||||
*
|
||||
* Features:
|
||||
* - Centered layout with max-width 1200px
|
||||
* - Modern styling with Tailwind CSS
|
||||
* - Displays all content metadata
|
||||
* - Responsive design
|
||||
*
|
||||
* Usage:
|
||||
* <ContentViewTemplate
|
||||
* content={contentData}
|
||||
* loading={false}
|
||||
* onBack={() => navigate('/writer/content')}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Content } from '../services/api';
|
||||
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../icons';
|
||||
|
||||
interface ContentViewTemplateProps {
|
||||
content: Content | null;
|
||||
loading: boolean;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<XCircleIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">The content you're looking for doesn't exist or has been deleted.</p>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back to Content List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower === 'generated' || statusLower === 'published' || statusLower === 'complete') {
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
}
|
||||
if (statusLower === 'pending' || statusLower === 'draft') {
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
}
|
||||
if (statusLower === 'failed' || statusLower === 'error') {
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Back Button */}
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mb-6 inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
<span className="font-medium">Back to Content</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main Content Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileTextIcon className="w-6 h-6" />
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(content.status)}`}>
|
||||
{content.status}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{content.meta_title || content.title || `Content #${content.id}`}
|
||||
</h1>
|
||||
{content.meta_description && (
|
||||
<p className="text-brand-50 text-lg leading-relaxed">
|
||||
{content.meta_description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<div className="px-8 py-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Generated</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatDate(content.generated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{content.updated_at && content.updated_at !== content.generated_at && (
|
||||
<div className="flex items-start gap-3">
|
||||
<ClockIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Last Updated</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatDate(content.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileTextIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Word Count</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{content.word_count?.toLocaleString() || 'N/A'} words
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task & Sector Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Related Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{content.task_title && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Task</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{content.task_title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">ID: {content.task_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{content.sector_name && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Sector</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{content.sector_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keywords & Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
SEO & Tags
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{content.primary_keyword && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Primary Keyword</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{content.primary_keyword}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{content.secondary_keywords && content.secondary_keywords.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Secondary Keywords</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.secondary_keywords.map((keyword, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs"
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags and Categories */}
|
||||
{(content.tags && content.tags.length > 0) || (content.categories && content.categories.length > 0) ? (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{content.tags && content.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{content.categories && content.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Categories:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.categories.map((category, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Image Status */}
|
||||
{(content.has_image_prompts || content.has_generated_images) && (
|
||||
<div className="px-8 py-4 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4">
|
||||
{content.has_image_prompts && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Image Prompts Generated</span>
|
||||
</div>
|
||||
)}
|
||||
{content.has_generated_images && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Images Generated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTML Content */}
|
||||
<div className="px-8 py-8">
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: content.html_content }}
|
||||
className="content-html"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata JSON (Collapsible) */}
|
||||
{content.metadata && Object.keys(content.metadata).length > 0 && (
|
||||
<div className="px-8 py-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||
View Full Metadata
|
||||
</summary>
|
||||
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
|
||||
{JSON.stringify(content.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Styles for Content HTML */}
|
||||
<style>{`
|
||||
.content-html {
|
||||
line-height: 1.75;
|
||||
}
|
||||
.content-html h1,
|
||||
.content-html h2,
|
||||
.content-html h3,
|
||||
.content-html h4 {
|
||||
font-weight: 700;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.content-html h1 { font-size: 2.25em; }
|
||||
.content-html h2 { font-size: 1.875em; }
|
||||
.content-html h3 { font-size: 1.5em; }
|
||||
.content-html h4 { font-size: 1.25em; }
|
||||
.content-html p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.content-html ul,
|
||||
.content-html ol {
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.content-html li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.content-html img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
.content-html a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.content-html a:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
.content-html blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1em;
|
||||
margin: 1.5em 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.content-html code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125em 0.375em;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.content-html pre {
|
||||
background-color: #f3f4f6;
|
||||
padding: 1em;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
.dark .content-html blockquote {
|
||||
border-left-color: #4b5563;
|
||||
}
|
||||
.dark .content-html code,
|
||||
.dark .content-html pre {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user