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:
IGNY8 VPS (Salman)
2025-11-12 01:24:44 +00:00
parent 18505de848
commit 645c6f3f9e
8 changed files with 594 additions and 20 deletions

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,7 @@ const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportuni
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
const Content = lazy(() => import("./pages/Writer/Content"));
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
const Images = lazy(() => import("./pages/Writer/Images"));
const Published = lazy(() => import("./pages/Writer/Published"));
@@ -159,11 +160,18 @@ export default function App() {
<Tasks />
</Suspense>
} />
{/* Writer Content Routes - Order matters: list route must come before detail route */}
<Route path="/writer/content" element={
<Suspense fallback={null}>
<Content />
</Suspense>
} />
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
<Route path="/writer/content/:id" element={
<Suspense fallback={null}>
<ContentView />
</Suspense>
} />
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={
<Suspense fallback={null}>

View File

@@ -0,0 +1,71 @@
/**
* ContentView Page - Displays individual content using ContentViewTemplate
* Route: /writer/content/:id
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import ContentViewTemplate from '../../templates/ContentViewTemplate';
import { fetchContentById, Content } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import PageMeta from '../../components/common/PageMeta';
export default function ContentView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [content, setContent] = useState<Content | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadContent = async () => {
// Validate ID parameter - must be a number
if (!id) {
toast.error('Content ID is required');
navigate('/writer/content');
return;
}
const contentId = parseInt(id, 10);
if (isNaN(contentId) || contentId <= 0) {
toast.error('Invalid content ID');
navigate('/writer/content');
return;
}
setLoading(true);
try {
const data = await fetchContentById(contentId);
setContent(data);
} catch (error: any) {
console.error('Error loading content:', error);
toast.error(`Failed to load content: ${error.message || 'Unknown error'}`);
setContent(null);
} finally {
setLoading(false);
}
};
loadContent();
}, [id, navigate, toast]);
const handleBack = () => {
navigate('/writer/content');
};
return (
<>
<PageMeta
title={content ? `${content.meta_title || content.title || `Content #${content.id}`} - IGNY8` : 'Content View - IGNY8'}
description={content?.meta_description || 'View content details and metadata'}
/>
<ContentViewTemplate
content={content}
loading={loading}
onBack={handleBack}
/>
</>
);
}

View File

@@ -1589,3 +1589,7 @@ export async function fetchContent(filters: ContentFilters = {}): Promise<Conten
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
}
export async function fetchContentById(id: number): Promise<Content> {
return fetchAPI(`/v1/writer/content/${id}/`);
}

View 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>
);
}