IMage genartion service and models revamp - #Migration Runs
This commit is contained in:
@@ -466,7 +466,7 @@ export default function ImageQueueModal({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
className="max-w-6xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
showCloseButton={!isProcessing}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -60,12 +60,9 @@ interface IntegrationConfig {
|
||||
runwareModel?: string; // Runware model: 'runware:97@1', etc.
|
||||
// Image generation settings
|
||||
image_type?: string; // 'realistic', 'artistic', 'cartoon'
|
||||
max_in_article_images?: number; // 1-5
|
||||
max_in_article_images?: number; // 1-4
|
||||
image_format?: string; // 'webp', 'jpg', 'png'
|
||||
desktop_enabled?: boolean;
|
||||
mobile_enabled?: boolean;
|
||||
featured_image_size?: string; // e.g., '1280x832', '1024x1024'
|
||||
desktop_image_size?: string; // e.g., '1024x1024', '512x512'
|
||||
featured_image_size?: string; // e.g., '1280x768', '1024x1024' - auto-determined by model
|
||||
}
|
||||
|
||||
export default function Integration() {
|
||||
@@ -90,12 +87,9 @@ export default function Integration() {
|
||||
model: 'dall-e-3', // OpenAI model if service is 'openai'
|
||||
runwareModel: 'runware:97@1', // Runware model if service is 'runware'
|
||||
image_type: 'realistic', // 'realistic', 'artistic', 'cartoon'
|
||||
max_in_article_images: 2, // 1-5
|
||||
max_in_article_images: 2, // 1-4
|
||||
image_format: 'webp', // 'webp', 'jpg', 'png'
|
||||
desktop_enabled: true,
|
||||
mobile_enabled: true,
|
||||
featured_image_size: '1024x1024', // Default, will be set based on provider/model
|
||||
desktop_image_size: '1024x1024', // Default, will be set based on provider/model
|
||||
featured_image_size: '1280x768', // Default, auto-determined by model
|
||||
},
|
||||
});
|
||||
|
||||
@@ -373,10 +367,7 @@ export default function Integration() {
|
||||
image_type: config.image_type || 'realistic',
|
||||
max_in_article_images: config.max_in_article_images || 2,
|
||||
image_format: config.image_format || 'webp',
|
||||
desktop_enabled: config.desktop_enabled !== undefined ? config.desktop_enabled : true,
|
||||
mobile_enabled: config.mobile_enabled !== undefined ? config.mobile_enabled : true,
|
||||
featured_image_size: config.featured_image_size || defaultFeaturedSize,
|
||||
desktop_image_size: config.desktop_image_size || defaultDesktopSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -435,12 +426,19 @@ export default function Integration() {
|
||||
};
|
||||
|
||||
// Get available image sizes with prices based on provider and model
|
||||
// Note: Sizes are now auto-determined - square (1024x1024) and landscape (model-specific)
|
||||
const getImageSizes = useCallback((provider: string, model: string) => {
|
||||
if (provider === 'runware') {
|
||||
// Model-specific landscape sizes, square is always 1024x1024
|
||||
const MODEL_LANDSCAPE_SIZES: Record<string, { value: string; label: string }> = {
|
||||
'runware:97@1': { value: '1280x768', label: '1280×768 pixels' }, // Hi Dream Full
|
||||
'bria:10@1': { value: '1344x768', label: '1344×768 pixels' }, // Bria 3.2
|
||||
'google:4@2': { value: '1376x768', label: '1376×768 pixels' }, // Nano Banana
|
||||
};
|
||||
const landscapeSize = MODEL_LANDSCAPE_SIZES[model] || { value: '1280x768', label: '1280×768 pixels' };
|
||||
return [
|
||||
{ value: '1280x832', label: '1280×832 pixels - $0.009', price: 0.009 },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels - $0.009', price: 0.009 },
|
||||
{ value: '512x512', label: '512×512 pixels - $0.006', price: 0.006 },
|
||||
{ value: landscapeSize.value, label: `${landscapeSize.label} - Landscape`, price: 0.009 },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels - Square', price: 0.009 },
|
||||
];
|
||||
} else if (provider === 'openai') {
|
||||
if (model === 'dall-e-2') {
|
||||
@@ -552,8 +550,9 @@ export default function Integration() {
|
||||
});
|
||||
},
|
||||
options: [
|
||||
{ value: 'runware:97@1', label: 'Hi Dream Full - Standard' },
|
||||
{ value: 'civitai:618692@691639', label: 'Bria 3.2 - Premium' },
|
||||
{ value: 'runware:97@1', label: 'Hi Dream Full - Basic' },
|
||||
{ value: 'bria:10@1', label: 'Bria 3.2 - Quality' },
|
||||
{ value: 'google:4@2', label: 'Nano Banana - Premium' },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -633,23 +632,19 @@ export default function Integration() {
|
||||
const availableSizes = getImageSizes(service, model);
|
||||
|
||||
if (availableSizes.length > 0) {
|
||||
const defaultSize = availableSizes[0].value;
|
||||
const defaultSize = availableSizes[0].value; // First option is landscape (featured image default)
|
||||
const currentFeaturedSize = config.featured_image_size;
|
||||
const currentDesktopSize = config.desktop_image_size;
|
||||
|
||||
// Check if current sizes are valid for the new provider/model
|
||||
// Check if current featured size is valid for the new provider/model
|
||||
const validSizes = availableSizes.map(s => s.value);
|
||||
const needsUpdate =
|
||||
!currentFeaturedSize || !validSizes.includes(currentFeaturedSize) ||
|
||||
!currentDesktopSize || !validSizes.includes(currentDesktopSize);
|
||||
const needsUpdate = !currentFeaturedSize || !validSizes.includes(currentFeaturedSize);
|
||||
|
||||
if (needsUpdate) {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[selectedIntegration]: {
|
||||
...config,
|
||||
featured_image_size: validSizes.includes(currentFeaturedSize || '') ? currentFeaturedSize : defaultSize,
|
||||
desktop_image_size: validSizes.includes(currentDesktopSize || '') ? currentDesktopSize : defaultSize,
|
||||
featured_image_size: defaultSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -743,67 +738,15 @@ export default function Integration() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Desktop & Mobile Images (2 columns) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Desktop Images Checkbox with Size Selector */}
|
||||
<div className="p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={integrations[selectedIntegration]?.desktop_enabled !== false}
|
||||
onChange={(checked) => {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[selectedIntegration]: {
|
||||
...integrations[selectedIntegration],
|
||||
desktop_enabled: checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Desktop Images
|
||||
</Label>
|
||||
</div>
|
||||
{integrations[selectedIntegration]?.desktop_enabled !== false && (
|
||||
<SelectDropdown
|
||||
options={getImageSizes(service, service === 'openai' ? (integrations[selectedIntegration]?.model || 'dall-e-3') : (integrations[selectedIntegration]?.runwareModel || 'runware:97@1'))}
|
||||
value={integrations[selectedIntegration]?.desktop_image_size || '1024x1024'}
|
||||
onChange={(value) => {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[selectedIntegration]: {
|
||||
...integrations[selectedIntegration],
|
||||
desktop_image_size: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Images Checkbox - Fixed to 512x512 */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Checkbox
|
||||
checked={integrations[selectedIntegration]?.mobile_enabled !== false}
|
||||
onChange={(checked) => {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[selectedIntegration]: {
|
||||
...integrations[selectedIntegration],
|
||||
mobile_enabled: checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Mobile Images
|
||||
</Label>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
512×512 pixels
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: Image Size Info */}
|
||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>Image Sizes (auto-determined):</strong>
|
||||
<ul className="mt-1 list-disc list-inside text-xs space-y-1">
|
||||
<li>Featured image: Landscape ({getImageSizes(service, service === 'openai' ? (integrations[selectedIntegration]?.model || 'dall-e-3') : (integrations[selectedIntegration]?.runwareModel || 'runware:97@1'))[0]?.label.split(' - ')[0] || 'model-specific'})</li>
|
||||
<li>In-article images: Alternating pattern (Square → Landscape → Square → Landscape)</li>
|
||||
<li>Square: 1024×1024 pixels (universal)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -921,8 +864,9 @@ export default function Integration() {
|
||||
? (() => {
|
||||
// Map model ID to display name
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'runware:97@1': 'Hi Dream Full - Standard',
|
||||
'civitai:618692@691639': 'Bria 3.2 - Premium',
|
||||
'runware:97@1': 'Hi Dream Full - Basic',
|
||||
'bria:10@1': 'Bria 3.2 - Quality',
|
||||
'google:4@2': 'Nano Banana - Premium',
|
||||
};
|
||||
return modelDisplayNames[integrations.image_generation.runwareModel] || integrations.image_generation.runwareModel;
|
||||
})()
|
||||
|
||||
@@ -86,21 +86,26 @@ export default function SiteSettings() {
|
||||
image_type: 'realistic' as 'realistic' | 'artistic' | 'cartoon',
|
||||
max_in_article_images: 2,
|
||||
image_format: 'webp' as 'webp' | 'jpg' | 'png',
|
||||
desktop_enabled: true,
|
||||
mobile_enabled: true,
|
||||
featured_image_size: '1024x1024',
|
||||
desktop_image_size: '1024x1024',
|
||||
});
|
||||
const [imageSettingsLoading, setImageSettingsLoading] = useState(false);
|
||||
const [imageSettingsSaving, setImageSettingsSaving] = useState(false);
|
||||
|
||||
// Image quality to config mapping
|
||||
// Updated to use new Runware models via API
|
||||
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
||||
standard: { service: 'openai', model: 'dall-e-2' },
|
||||
premium: { service: 'openai', model: 'dall-e-3' },
|
||||
best: { service: 'runware', model: 'runware:97@1' },
|
||||
best: { service: 'runware', model: 'runware:97@1' }, // Uses model-specific landscape size
|
||||
};
|
||||
|
||||
// Runware model choices with descriptions
|
||||
const RUNWARE_MODEL_CHOICES = [
|
||||
{ value: 'runware:97@1', label: 'Hi Dream Full - Basic', description: 'Fast & affordable' },
|
||||
{ value: 'bria:10@1', label: 'Bria 3.2 - Quality', description: 'Commercial-safe, licensed data' },
|
||||
{ value: 'google:4@2', label: 'Nano Banana - Premium', description: 'Best quality, text rendering' },
|
||||
];
|
||||
|
||||
const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => {
|
||||
if (service === 'runware') return 'best';
|
||||
if (model === 'dall-e-3') return 'premium';
|
||||
@@ -109,10 +114,11 @@ export default function SiteSettings() {
|
||||
|
||||
const getImageSizes = (provider: string, model: string) => {
|
||||
if (provider === 'runware') {
|
||||
// Model-specific sizes - featured uses landscape, in-article alternates
|
||||
// Sizes shown are for featured image (landscape)
|
||||
return [
|
||||
{ value: '1280x832', label: '1280×832 pixels' },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
{ value: '512x512', label: '512×512 pixels' },
|
||||
{ value: '1280x768', label: '1280×768 (Landscape)' },
|
||||
{ value: '1024x1024', label: '1024×1024 (Square)' },
|
||||
];
|
||||
} else if (provider === 'openai') {
|
||||
if (model === 'dall-e-2') {
|
||||
@@ -230,16 +236,14 @@ export default function SiteSettings() {
|
||||
|
||||
const validSizes = sizes.map(s => s.value);
|
||||
const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size);
|
||||
const needsDesktopUpdate = !validSizes.includes(imageSettings.desktop_image_size);
|
||||
|
||||
if (needsFeaturedUpdate || needsDesktopUpdate) {
|
||||
if (needsFeaturedUpdate) {
|
||||
setImageSettings(prev => ({
|
||||
...prev,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size,
|
||||
desktop_image_size: needsDesktopUpdate ? defaultSize : prev.desktop_image_size,
|
||||
}));
|
||||
} else {
|
||||
setImageSettings(prev => ({
|
||||
@@ -438,10 +442,7 @@ export default function SiteSettings() {
|
||||
image_type: imageData.image_type || 'realistic',
|
||||
max_in_article_images: imageData.max_in_article_images || 2,
|
||||
image_format: imageData.image_format || 'webp',
|
||||
desktop_enabled: imageData.desktop_enabled !== false,
|
||||
mobile_enabled: imageData.mobile_enabled !== false,
|
||||
featured_image_size: imageData.featured_image_size || '1024x1024',
|
||||
desktop_image_size: imageData.desktop_image_size || '1024x1024',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -464,10 +465,7 @@ export default function SiteSettings() {
|
||||
image_type: imageSettings.image_type,
|
||||
max_in_article_images: imageSettings.max_in_article_images,
|
||||
image_format: imageSettings.image_format,
|
||||
desktop_enabled: imageSettings.desktop_enabled,
|
||||
mobile_enabled: imageSettings.mobile_enabled,
|
||||
featured_image_size: imageSettings.featured_image_size,
|
||||
desktop_image_size: imageSettings.desktop_image_size,
|
||||
};
|
||||
|
||||
await fetchAPI('/v1/system/settings/integrations/image_generation/save/', {
|
||||
@@ -1023,7 +1021,7 @@ export default function SiteSettings() {
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-500 to-brand-500 text-white">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-medium">Featured Image Size</div>
|
||||
<div className="text-xs bg-white/20 px-2 py-1 rounded">Always Enabled</div>
|
||||
<div className="text-xs bg-white/20 px-2 py-1 rounded">Landscape (Model-specific)</div>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
options={availableImageSizes}
|
||||
@@ -1031,42 +1029,13 @@ export default function SiteSettings() {
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, featured_image_size: value })}
|
||||
className="w-full [&_.igny8-select-styled]:bg-white/10 [&_.igny8-select-styled]:border-white/20 [&_.igny8-select-styled]:text-white"
|
||||
/>
|
||||
<p className="text-xs text-white/70 mt-2">
|
||||
In-article images alternate: Square (1024×1024) → Landscape → Square → Landscape
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Desktop & Mobile Images */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={imageSettings.desktop_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, desktop_enabled: checked })}
|
||||
/>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Desktop Images</Label>
|
||||
</div>
|
||||
{imageSettings.desktop_enabled && (
|
||||
<SelectDropdown
|
||||
options={availableImageSizes}
|
||||
value={imageSettings.desktop_image_size}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, desktop_image_size: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Checkbox
|
||||
checked={imageSettings.mobile_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, mobile_enabled: checked })}
|
||||
/>
|
||||
<div>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Mobile Images</Label>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">512×512 pixels</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Max Images & Format */}
|
||||
{/* Row 3: Max Images & Format */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Max In-Article Images</Label>
|
||||
@@ -1076,12 +1045,14 @@ export default function SiteSettings() {
|
||||
{ value: '2', label: '2 Images' },
|
||||
{ value: '3', label: '3 Images' },
|
||||
{ value: '4', label: '4 Images' },
|
||||
{ value: '5', label: '5 Images' },
|
||||
]}
|
||||
value={String(imageSettings.max_in_article_images)}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Images 1 & 3: Square | Images 2 & 4: Landscape
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1409,6 +1409,7 @@ export interface ImageRecord {
|
||||
caption?: string | null;
|
||||
status: string;
|
||||
position: number;
|
||||
aspect_ratio?: 'square' | 'landscape'; // square for position 0,2 | landscape for position 1,3
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
account_id?: number | null;
|
||||
|
||||
@@ -390,37 +390,53 @@ const splitAtFirstH3 = (html: string): { beforeH3: string; h3AndAfter: string }
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to check if section contains a table
|
||||
const hasTable = (html: string): boolean => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
return doc.querySelector('table') !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* ContentSectionBlock - Renders a content section with image layout based on aspect ratio
|
||||
*
|
||||
* Layout rules:
|
||||
* - Single landscape image: 100% width (full width)
|
||||
* - Single square image: 50% width (centered)
|
||||
* - Two square images (paired): Side by side (50% each)
|
||||
*/
|
||||
const ContentSectionBlock = ({
|
||||
section,
|
||||
image,
|
||||
loading,
|
||||
index,
|
||||
imagePlacement = 'right',
|
||||
firstImage = null,
|
||||
aspectRatio = 'square',
|
||||
pairedSquareImage = null,
|
||||
}: {
|
||||
section: ArticleSection;
|
||||
image: ImageRecord | null;
|
||||
loading: boolean;
|
||||
index: number;
|
||||
imagePlacement?: 'left' | 'center' | 'right';
|
||||
firstImage?: ImageRecord | null;
|
||||
aspectRatio?: 'square' | 'landscape';
|
||||
pairedSquareImage?: ImageRecord | null;
|
||||
}) => {
|
||||
const hasImage = Boolean(image);
|
||||
const hasPairedImage = Boolean(pairedSquareImage);
|
||||
const headingLabel = section.heading || `Section ${index + 1}`;
|
||||
const sectionHasTable = hasTable(section.bodyHtml);
|
||||
const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml);
|
||||
|
||||
// Determine image container width class based on aspect ratio and pairing
|
||||
const getImageContainerClass = () => {
|
||||
if (hasPairedImage) {
|
||||
// Two squares side by side
|
||||
return 'w-full';
|
||||
}
|
||||
if (aspectRatio === 'landscape') {
|
||||
// Landscape: 100% width
|
||||
return 'w-full';
|
||||
}
|
||||
// Single square: 50% width centered
|
||||
return 'w-full max-w-[50%]';
|
||||
};
|
||||
|
||||
return (
|
||||
<section id={section.id} className="group/section scroll-mt-24">
|
||||
<div className="overflow-hidden rounded-3xl border border-gray-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm transition-transform duration-300 group-hover/section:-translate-y-1 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
|
||||
<div className="flex flex-col gap-6 p-8 sm:p-10">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-brand-500/10 text-sm font-semibold text-brand-600 dark:bg-brand-500/20 dark:text-brand-300">
|
||||
{index + 1}
|
||||
@@ -435,86 +451,51 @@ const ContentSectionBlock = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{imagePlacement === 'center' && hasImage ? (
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Content before H3 */}
|
||||
{beforeH3 && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Centered image before H3 */}
|
||||
{/* Content layout with images */}
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Content before H3 */}
|
||||
{beforeH3 && (
|
||||
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image section - layout depends on aspect ratio */}
|
||||
{hasImage && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[60%]">
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
{hasPairedImage ? (
|
||||
// Two squares side by side (50% each)
|
||||
<div className="grid w-full grid-cols-2 gap-6">
|
||||
<div className="w-full">
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<SectionImageBlock image={pairedSquareImage} loading={loading} heading={`${headingLabel} (2)`} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Single image with width based on aspect ratio
|
||||
<div className={getImageContainerClass()}>
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* H3 and remaining content */}
|
||||
{h3AndAfter && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback if no H3 found */}
|
||||
{!beforeH3 && !h3AndAfter && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : sectionHasTable && hasImage && firstImage ? (
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Content before H3 */}
|
||||
{beforeH3 && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two images side by side at 50% width each */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="w-full">
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<SectionImageBlock image={firstImage} loading={loading} heading="First Article Image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* H3 and remaining content */}
|
||||
{h3AndAfter && (
|
||||
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
||||
</div>
|
||||
|
||||
{/* H3 and remaining content */}
|
||||
{h3AndAfter && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback if no H3 found */}
|
||||
{!beforeH3 && !h3AndAfter && (
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={hasImage ? `grid gap-10 ${imagePlacement === 'left' ? 'lg:grid-cols-[minmax(0,40%)_minmax(0,60%)]' : 'lg:grid-cols-[minmax(0,60%)_minmax(0,40%)]'}` : ''}>
|
||||
{imagePlacement === 'left' && hasImage && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||
)}
|
||||
|
||||
{/* Fallback if no H3 structure found */}
|
||||
{!beforeH3 && !h3AndAfter && (
|
||||
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
||||
</div>
|
||||
{imagePlacement === 'right' && hasImage && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -532,12 +513,21 @@ interface ArticleBodyProps {
|
||||
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
|
||||
const hasStructuredSections = sections.length > 0;
|
||||
|
||||
// Calculate image placement: right → center → left → repeat
|
||||
const getImagePlacement = (index: number): 'left' | 'center' | 'right' => {
|
||||
const position = index % 3;
|
||||
if (position === 0) return 'right';
|
||||
if (position === 1) return 'center';
|
||||
return 'left';
|
||||
// Determine image aspect ratio from record or fallback to position-based calculation
|
||||
// Position 0, 2 = square (1024x1024), Position 1, 3 = landscape (model-specific)
|
||||
const getImageAspectRatio = (image: ImageRecord | null, index: number): 'square' | 'landscape' => {
|
||||
if (image?.aspect_ratio) return image.aspect_ratio;
|
||||
// Fallback: even positions (0, 2) are square, odd positions (1, 3) are landscape
|
||||
return index % 2 === 0 ? 'square' : 'landscape';
|
||||
};
|
||||
|
||||
// Check if two consecutive images are both squares (for side-by-side layout)
|
||||
const getNextSquareImage = (currentIndex: number): ImageRecord | null => {
|
||||
const nextImage = sectionImages[currentIndex + 1];
|
||||
if (nextImage && getImageAspectRatio(nextImage, currentIndex + 1) === 'square') {
|
||||
return nextImage;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!hasStructuredSections && !introHtml && rawHtml) {
|
||||
@@ -553,20 +543,42 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
||||
// Get the first in-article image (position 0)
|
||||
const firstImage = sectionImages.length > 0 ? sectionImages[0] : null;
|
||||
|
||||
// Track which images have been rendered as pairs (to skip the second in the pair)
|
||||
const renderedPairIndices = new Set<number>();
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{introHtml && <IntroBlock html={introHtml} />}
|
||||
{sections.map((section, index) => (
|
||||
<ContentSectionBlock
|
||||
key={section.id || `section-${index}`}
|
||||
section={section}
|
||||
image={sectionImages[index] ?? null}
|
||||
loading={imagesLoading}
|
||||
index={index}
|
||||
imagePlacement={getImagePlacement(index)}
|
||||
firstImage={firstImage}
|
||||
/>
|
||||
))}
|
||||
{sections.map((section, index) => {
|
||||
// Skip if this image was already rendered as part of a pair
|
||||
if (renderedPairIndices.has(index)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentImage = sectionImages[index] ?? null;
|
||||
const currentAspectRatio = getImageAspectRatio(currentImage, index);
|
||||
|
||||
// Check if current is square and next is also square for side-by-side layout
|
||||
let pairedSquareImage: ImageRecord | null = null;
|
||||
if (currentAspectRatio === 'square') {
|
||||
pairedSquareImage = getNextSquareImage(index);
|
||||
if (pairedSquareImage) {
|
||||
renderedPairIndices.add(index + 1); // Mark next as rendered
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentSectionBlock
|
||||
key={section.id || `section-${index}`}
|
||||
section={section}
|
||||
image={currentImage}
|
||||
loading={imagesLoading}
|
||||
index={index}
|
||||
aspectRatio={currentAspectRatio}
|
||||
pairedSquareImage={pairedSquareImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user