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 pathlib import Path
|
||||
|
||||
# Create /data/app/images directory if it doesn't exist
|
||||
# Try absolute path first, fallback to project-relative if needed
|
||||
images_dir = '/data/app/images'
|
||||
# Create images directory if it doesn't exist
|
||||
# Use /data/app/igny8/images (mounted volume) for persistence
|
||||
# Fallback to /data/app/images if mounted path not available
|
||||
images_dir = '/data/app/igny8/images' # Use mounted volume path
|
||||
write_test_passed = False
|
||||
|
||||
try:
|
||||
@@ -549,16 +550,13 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
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:
|
||||
logger.warning(f"[process_image_generation_queue] Image {image_id} - Directory not writable: {images_dir}, error: {write_test_error}")
|
||||
# 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')
|
||||
logger.warning(f"[process_image_generation_queue] Image {image_id} - Mounted directory not writable: {images_dir}, error: {write_test_error}")
|
||||
# Fallback to /data/app/images
|
||||
images_dir = '/data/app/images'
|
||||
try:
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
# Test fallback directory write access
|
||||
test_file = os.path.join(images_dir, '.write_test')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
@@ -566,8 +564,22 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
write_test_passed = True
|
||||
logger.info(f"[process_image_generation_queue] Image {image_id} - Using fallback directory (writable): {images_dir}")
|
||||
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}")
|
||||
raise Exception(f"Neither /data/app/images nor {images_dir} is writable. Last error: {fallback_error}")
|
||||
logger.warning(f"[process_image_generation_queue] Image {image_id} - Fallback directory also not writable: {images_dir}, 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:
|
||||
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)")
|
||||
|
||||
try:
|
||||
# Save file path if available, otherwise save URL
|
||||
# Save file path and URL appropriately
|
||||
if saved_file_path:
|
||||
# Store relative path or full path in image_url field
|
||||
image.image_url = saved_file_path
|
||||
else:
|
||||
# Store local file path in image_path field
|
||||
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:
|
||||
# Only URL available, save to 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'
|
||||
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")
|
||||
except Exception as 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 + [{
|
||||
'image_id': image_id,
|
||||
'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')
|
||||
}]
|
||||
}
|
||||
@@ -668,7 +695,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
results.append({
|
||||
'image_id': image_id,
|
||||
'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')
|
||||
})
|
||||
completed += 1
|
||||
|
||||
@@ -367,6 +367,76 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
else:
|
||||
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')
|
||||
def auto_generate_images(self, request):
|
||||
"""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 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}>
|
||||
|
||||
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}` : ''}`);
|
||||
}
|
||||
|
||||
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