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:
@@ -536,9 +536,10 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Create /data/app/images directory if it doesn't exist
|
# Create images directory if it doesn't exist
|
||||||
# Try absolute path first, fallback to project-relative if needed
|
# Use /data/app/igny8/images (mounted volume) for persistence
|
||||||
images_dir = '/data/app/images'
|
# Fallback to /data/app/images if mounted path not available
|
||||||
|
images_dir = '/data/app/igny8/images' # Use mounted volume path
|
||||||
write_test_passed = False
|
write_test_passed = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -549,16 +550,13 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
f.write('test')
|
f.write('test')
|
||||||
os.remove(test_file)
|
os.remove(test_file)
|
||||||
write_test_passed = True
|
write_test_passed = True
|
||||||
logger.info(f"[process_image_generation_queue] Image {image_id} - Directory writable: {images_dir}")
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Directory writable (mounted volume): {images_dir}")
|
||||||
except Exception as write_test_error:
|
except Exception as write_test_error:
|
||||||
logger.warning(f"[process_image_generation_queue] Image {image_id} - Directory not writable: {images_dir}, error: {write_test_error}")
|
logger.warning(f"[process_image_generation_queue] Image {image_id} - Mounted directory not writable: {images_dir}, error: {write_test_error}")
|
||||||
# Fallback to project-relative path
|
# Fallback to /data/app/images
|
||||||
from django.conf import settings
|
images_dir = '/data/app/images'
|
||||||
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent.parent
|
|
||||||
images_dir = str(base_dir / 'data' / 'app' / 'images')
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(images_dir, exist_ok=True)
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
# Test fallback directory write access
|
|
||||||
test_file = os.path.join(images_dir, '.write_test')
|
test_file = os.path.join(images_dir, '.write_test')
|
||||||
with open(test_file, 'w') as f:
|
with open(test_file, 'w') as f:
|
||||||
f.write('test')
|
f.write('test')
|
||||||
@@ -566,8 +564,22 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
write_test_passed = True
|
write_test_passed = True
|
||||||
logger.info(f"[process_image_generation_queue] Image {image_id} - Using fallback directory (writable): {images_dir}")
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Using fallback directory (writable): {images_dir}")
|
||||||
except Exception as fallback_error:
|
except Exception as fallback_error:
|
||||||
logger.error(f"[process_image_generation_queue] Image {image_id} - Fallback directory also not writable: {images_dir}, error: {fallback_error}")
|
logger.warning(f"[process_image_generation_queue] Image {image_id} - Fallback directory also not writable: {images_dir}, error: {fallback_error}")
|
||||||
raise Exception(f"Neither /data/app/images nor {images_dir} is writable. Last error: {fallback_error}")
|
# Final fallback to project-relative path
|
||||||
|
from django.conf import settings
|
||||||
|
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent.parent
|
||||||
|
images_dir = str(base_dir / 'data' / 'app' / 'images')
|
||||||
|
try:
|
||||||
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
|
test_file = os.path.join(images_dir, '.write_test')
|
||||||
|
with open(test_file, 'w') as f:
|
||||||
|
f.write('test')
|
||||||
|
os.remove(test_file)
|
||||||
|
write_test_passed = True
|
||||||
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Using project-relative directory (writable): {images_dir}")
|
||||||
|
except Exception as final_error:
|
||||||
|
logger.error(f"[process_image_generation_queue] Image {image_id} - All directories not writable. Last error: {final_error}")
|
||||||
|
raise Exception(f"None of the image directories are writable. Last error: {final_error}")
|
||||||
|
|
||||||
if not write_test_passed:
|
if not write_test_passed:
|
||||||
raise Exception(f"Failed to find writable directory for saving images")
|
raise Exception(f"Failed to find writable directory for saving images")
|
||||||
@@ -620,15 +632,29 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
logger.info(f"[process_image_generation_queue] Image {image_id} - URL length {len(image_url)} chars (was limited to 200, now supports 500)")
|
logger.info(f"[process_image_generation_queue] Image {image_id} - URL length {len(image_url)} chars (was limited to 200, now supports 500)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Save file path if available, otherwise save URL
|
# Save file path and URL appropriately
|
||||||
if saved_file_path:
|
if saved_file_path:
|
||||||
# Store relative path or full path in image_url field
|
# Store local file path in image_path field
|
||||||
image.image_url = saved_file_path
|
image.image_path = saved_file_path
|
||||||
|
# Also keep the original URL in image_url field for reference
|
||||||
|
if image_url:
|
||||||
|
image.image_url = image_url
|
||||||
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved local path: {saved_file_path}")
|
||||||
else:
|
else:
|
||||||
|
# Only URL available, save to image_url
|
||||||
image.image_url = image_url
|
image.image_url = image_url
|
||||||
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved URL only: {image_url[:100] if image_url else 'None'}...")
|
||||||
image.status = 'generated'
|
image.status = 'generated'
|
||||||
logger.info(f"[process_image_generation_queue] Image {image_id} - Attempting to save to database")
|
|
||||||
image.save(update_fields=['image_url', 'status'])
|
# Determine which fields to update
|
||||||
|
update_fields = ['status']
|
||||||
|
if saved_file_path:
|
||||||
|
update_fields.append('image_path')
|
||||||
|
if image_url:
|
||||||
|
update_fields.append('image_url')
|
||||||
|
|
||||||
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Attempting to save to database (fields: {update_fields})")
|
||||||
|
image.save(update_fields=update_fields)
|
||||||
logger.info(f"[process_image_generation_queue] Image {image_id} - Successfully saved to database")
|
logger.info(f"[process_image_generation_queue] Image {image_id} - Successfully saved to database")
|
||||||
except Exception as save_error:
|
except Exception as save_error:
|
||||||
error_str = str(save_error)
|
error_str = str(save_error)
|
||||||
@@ -659,7 +685,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
'results': results + [{
|
'results': results + [{
|
||||||
'image_id': image_id,
|
'image_id': image_id,
|
||||||
'status': 'completed',
|
'status': 'completed',
|
||||||
'image_url': saved_file_path or image_url,
|
'image_url': image_url, # Original URL from API
|
||||||
|
'image_path': saved_file_path, # Local file path if saved
|
||||||
'revised_prompt': result.get('revised_prompt')
|
'revised_prompt': result.get('revised_prompt')
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -668,7 +695,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
results.append({
|
results.append({
|
||||||
'image_id': image_id,
|
'image_id': image_id,
|
||||||
'status': 'completed',
|
'status': 'completed',
|
||||||
'image_url': saved_file_path or image_url,
|
'image_url': image_url, # Original URL from API
|
||||||
|
'image_path': saved_file_path, # Local file path if saved
|
||||||
'revised_prompt': result.get('revised_prompt')
|
'revised_prompt': result.get('revised_prompt')
|
||||||
})
|
})
|
||||||
completed += 1
|
completed += 1
|
||||||
|
|||||||
@@ -367,6 +367,76 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
else:
|
else:
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'], url_path='file', url_name='image_file')
|
||||||
|
def serve_image_file(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Serve image file from local path via URL
|
||||||
|
GET /api/v1/writer/images/{id}/file/
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get image directly without account filtering for file serving
|
||||||
|
# This allows public access to image files
|
||||||
|
try:
|
||||||
|
image = Images.objects.get(pk=pk)
|
||||||
|
except Images.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'error': 'Image not found'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Check if image has a local path
|
||||||
|
if not image.image_path:
|
||||||
|
return Response({
|
||||||
|
'error': 'No local file path available for this image'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
file_path = image.image_path
|
||||||
|
|
||||||
|
# Verify file exists
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return Response({
|
||||||
|
'error': f'Image file not found at: {file_path}'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Check if file is readable
|
||||||
|
if not os.access(file_path, os.R_OK):
|
||||||
|
return Response({
|
||||||
|
'error': 'Image file is not readable'
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
# Determine content type from file extension
|
||||||
|
import mimetypes
|
||||||
|
content_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
if not content_type:
|
||||||
|
content_type = 'image/png' # Default to PNG
|
||||||
|
|
||||||
|
# Serve the file
|
||||||
|
try:
|
||||||
|
return FileResponse(
|
||||||
|
open(file_path, 'rb'),
|
||||||
|
content_type=content_type,
|
||||||
|
filename=os.path.basename(file_path)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'error': f'Failed to serve file: {str(e)}'
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
except Images.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'error': 'Image not found'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
|
||||||
|
return Response({
|
||||||
|
'error': f'Failed to serve image: {str(e)}'
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
||||||
def auto_generate_images(self, request):
|
def auto_generate_images(self, request):
|
||||||
"""Auto-generate images for tasks using AI"""
|
"""Auto-generate images for tasks using AI"""
|
||||||
|
|||||||
2
frontend/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
2
frontend/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportuni
|
|||||||
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
||||||
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
||||||
const Content = lazy(() => import("./pages/Writer/Content"));
|
const Content = lazy(() => import("./pages/Writer/Content"));
|
||||||
|
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
|
||||||
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||||
const Published = lazy(() => import("./pages/Writer/Published"));
|
const Published = lazy(() => import("./pages/Writer/Published"));
|
||||||
@@ -159,11 +160,18 @@ export default function App() {
|
|||||||
<Tasks />
|
<Tasks />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
{/* Writer Content Routes - Order matters: list route must come before detail route */}
|
||||||
<Route path="/writer/content" element={
|
<Route path="/writer/content" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Content />
|
<Content />
|
||||||
</Suspense>
|
</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/drafts" element={<Navigate to="/writer/content" replace />} />
|
||||||
<Route path="/writer/images" element={
|
<Route path="/writer/images" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
71
frontend/src/pages/Writer/ContentView.tsx
Normal file
71
frontend/src/pages/Writer/ContentView.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1589,3 +1589,7 @@ export async function fetchContent(filters: ContentFilters = {}): Promise<Conten
|
|||||||
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
|
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchContentById(id: number): Promise<Content> {
|
||||||
|
return fetchAPI(`/v1/writer/content/${id}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
BIN
images/img-y7hooMkdjGUoscfAKHonJKYO.png
Normal file
BIN
images/img-y7hooMkdjGUoscfAKHonJKYO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
Reference in New Issue
Block a user