687 lines
27 KiB
TypeScript
687 lines
27 KiB
TypeScript
/**
|
||
* Content Settings Page - 3 Tabs
|
||
* Tabs: Content Generation, Publishing, Image Settings
|
||
* Consolidated settings for content creation workflow
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useLocation } from 'react-router-dom';
|
||
import {
|
||
SaveIcon, Loader2Icon, ImageIcon, FileTextIcon, PaperPlaneIcon, SettingsIcon
|
||
} from '../../icons';
|
||
import { Card } from '../../components/ui/card';
|
||
import Button from '../../components/ui/button/Button';
|
||
import { fetchAPI } from '../../services/api';
|
||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||
import Label from '../../components/form/Label';
|
||
import Checkbox from '../../components/form/input/Checkbox';
|
||
import TextArea from '../../components/form/input/TextArea';
|
||
import PageMeta from '../../components/common/PageMeta';
|
||
import PageHeader from '../../components/common/PageHeader';
|
||
import { BoxCubeIcon } from '../../icons';
|
||
|
||
type TabType = 'content' | 'publishing' | 'images';
|
||
|
||
interface ImageGenerationSettings {
|
||
enabled: boolean;
|
||
service: 'openai' | 'runware';
|
||
provider: string;
|
||
model: string;
|
||
runwareModel?: string;
|
||
image_type: 'realistic' | 'artistic' | 'cartoon';
|
||
max_in_article_images: number;
|
||
image_format: 'webp' | 'jpg' | 'png';
|
||
desktop_enabled: boolean;
|
||
mobile_enabled: boolean;
|
||
featured_image_size: string;
|
||
desktop_image_size: string;
|
||
}
|
||
|
||
interface PublishingSettings {
|
||
autoPublishEnabled: boolean;
|
||
autoSyncEnabled: boolean;
|
||
}
|
||
|
||
interface ContentGenerationSettings {
|
||
appendToPrompt: string;
|
||
defaultTone: string;
|
||
defaultLength: string;
|
||
}
|
||
|
||
// AI Model Config from API
|
||
interface AIModelConfig {
|
||
model_name: string;
|
||
display_name: string;
|
||
model_type: string;
|
||
provider: string;
|
||
valid_sizes?: string[];
|
||
quality_tier?: string;
|
||
credits_per_image?: number;
|
||
}
|
||
|
||
// Map user-friendly quality to internal service/model configuration
|
||
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' },
|
||
};
|
||
|
||
// Map internal config back to user-friendly quality
|
||
const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => {
|
||
if (service === 'runware') return 'best';
|
||
if (model === 'dall-e-3') return 'premium';
|
||
return 'standard';
|
||
};
|
||
|
||
|
||
// Get available image sizes based on provider and model (from API or fallback)
|
||
const getImageSizes = (provider: string, model: string, imageModels: AIModelConfig[]) => {
|
||
// First, try to find the model in the fetched models
|
||
const modelConfig = imageModels.find(m =>
|
||
m.model_name === model ||
|
||
(m.provider === provider && m.model_name.includes(model))
|
||
);
|
||
|
||
// If found and has valid_sizes, use them
|
||
if (modelConfig?.valid_sizes && modelConfig.valid_sizes.length > 0) {
|
||
return modelConfig.valid_sizes.map(size => ({
|
||
value: size,
|
||
label: `${size.replace('x', '×')} pixels`
|
||
}));
|
||
}
|
||
|
||
// Fallback to hardcoded sizes for backward compatibility
|
||
if (provider === 'runware') {
|
||
return [
|
||
{ value: '1280x832', label: '1280×832 pixels' },
|
||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||
{ value: '512x512', label: '512×512 pixels' },
|
||
];
|
||
} else if (provider === 'openai') {
|
||
if (model === 'dall-e-2') {
|
||
return [
|
||
{ value: '256x256', label: '256×256 pixels' },
|
||
{ value: '512x512', label: '512×512 pixels' },
|
||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||
];
|
||
} else if (model === 'dall-e-3') {
|
||
return [
|
||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||
];
|
||
}
|
||
}
|
||
return [{ value: '1024x1024', label: '1024×1024 pixels' }];
|
||
};
|
||
|
||
// Get tab from URL path
|
||
function getTabFromPath(pathname: string): TabType {
|
||
if (pathname.includes('/publishing')) return 'publishing';
|
||
if (pathname.includes('/images')) return 'images';
|
||
return 'content';
|
||
}
|
||
|
||
export default function ContentSettingsPage() {
|
||
const toast = useToast();
|
||
const location = useLocation();
|
||
const activeTab = getTabFromPath(location.pathname);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
// Image Models from API - for dynamic size options
|
||
const [imageModels, setImageModels] = useState<AIModelConfig[]>([]);
|
||
|
||
// Content Generation Settings
|
||
const [contentSettings, setContentSettings] = useState<ContentGenerationSettings>({
|
||
appendToPrompt: '',
|
||
defaultTone: 'professional',
|
||
defaultLength: 'medium',
|
||
});
|
||
|
||
// Publishing Settings
|
||
const [publishingSettings, setPublishingSettings] = useState<PublishingSettings>({
|
||
autoPublishEnabled: false,
|
||
autoSyncEnabled: false,
|
||
});
|
||
|
||
// Image Quality
|
||
const [imageQuality, setImageQuality] = useState<'standard' | 'premium' | 'best'>('premium');
|
||
|
||
// Image Generation Settings
|
||
const [imageSettings, setImageSettings] = useState<ImageGenerationSettings>({
|
||
enabled: true,
|
||
service: 'openai',
|
||
provider: 'openai',
|
||
model: 'dall-e-3',
|
||
image_type: 'realistic',
|
||
max_in_article_images: 2,
|
||
image_format: 'webp',
|
||
desktop_enabled: true,
|
||
mobile_enabled: true,
|
||
featured_image_size: '1024x1024',
|
||
desktop_image_size: '1024x1024',
|
||
});
|
||
|
||
// Get current provider/model from quality setting
|
||
const getCurrentConfig = useCallback(() => {
|
||
const config = QUALITY_TO_CONFIG[imageQuality];
|
||
return {
|
||
service: config.service,
|
||
model: config.model,
|
||
};
|
||
}, [imageQuality]);
|
||
|
||
// Get available sizes for current quality (uses imageModels from API)
|
||
const availableSizes = getImageSizes(
|
||
getCurrentConfig().service,
|
||
getCurrentConfig().model,
|
||
imageModels
|
||
);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
}, []);
|
||
|
||
// Update image sizes when quality changes or imageModels are loaded
|
||
useEffect(() => {
|
||
const config = getCurrentConfig();
|
||
const sizes = getImageSizes(config.service, config.model, imageModels);
|
||
const defaultSize = sizes.length > 0 ? sizes[0].value : '1024x1024';
|
||
|
||
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) {
|
||
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 => ({
|
||
...prev,
|
||
service: config.service,
|
||
provider: config.service,
|
||
model: config.model,
|
||
}));
|
||
}
|
||
}, [imageQuality, getCurrentConfig, imageModels]);
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
setLoading(true);
|
||
|
||
// Load available image models from API (for dynamic sizes)
|
||
try {
|
||
const modelsResponse = await fetchAPI('/v1/billing/models/?type=image');
|
||
if (modelsResponse?.data) {
|
||
setImageModels(modelsResponse.data);
|
||
} else if (Array.isArray(modelsResponse)) {
|
||
setImageModels(modelsResponse);
|
||
}
|
||
} catch (err) {
|
||
console.log('Image models not available, using hardcoded sizes');
|
||
}
|
||
|
||
// Load image generation settings
|
||
const imageData = await fetchAPI('/v1/system/settings/integrations/image_generation/');
|
||
if (imageData) {
|
||
const quality = getQualityFromConfig(imageData.service || imageData.provider, imageData.model);
|
||
setImageQuality(quality);
|
||
|
||
setImageSettings({
|
||
enabled: imageData.enabled !== false,
|
||
service: imageData.service || imageData.provider || 'openai',
|
||
provider: imageData.provider || imageData.service || 'openai',
|
||
model: imageData.model || 'dall-e-3',
|
||
runwareModel: imageData.runwareModel,
|
||
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',
|
||
});
|
||
}
|
||
|
||
// Load content generation settings
|
||
try {
|
||
const contentData = await fetchAPI('/v1/system/settings/content/content_generation/');
|
||
if (contentData?.config) {
|
||
setContentSettings({
|
||
appendToPrompt: contentData.config.append_to_prompt || '',
|
||
defaultTone: contentData.config.default_tone || 'professional',
|
||
defaultLength: contentData.config.default_length || 'medium',
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// Settings may not exist yet, use defaults
|
||
console.log('Content generation settings not found, using defaults');
|
||
}
|
||
|
||
// Load publishing settings
|
||
try {
|
||
const publishData = await fetchAPI('/v1/system/settings/content/publishing/');
|
||
if (publishData?.config) {
|
||
setPublishingSettings({
|
||
autoPublishEnabled: publishData.config.auto_publish_enabled || false,
|
||
autoSyncEnabled: publishData.config.auto_sync_enabled || false,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// Settings may not exist yet, use defaults
|
||
console.log('Publishing settings not found, using defaults');
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('Error loading content settings:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
setSaving(true);
|
||
|
||
if (activeTab === 'images') {
|
||
const config = getCurrentConfig();
|
||
const configToSave = {
|
||
enabled: imageSettings.enabled,
|
||
service: config.service,
|
||
provider: config.service,
|
||
model: config.model,
|
||
runwareModel: config.service === 'runware' ? config.model : undefined,
|
||
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/', {
|
||
method: 'POST',
|
||
body: JSON.stringify(configToSave),
|
||
});
|
||
}
|
||
|
||
// Save content generation settings
|
||
if (activeTab === 'content') {
|
||
await fetchAPI('/v1/system/settings/content/content_generation/save/', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
config: {
|
||
append_to_prompt: contentSettings.appendToPrompt,
|
||
default_tone: contentSettings.defaultTone,
|
||
default_length: contentSettings.defaultLength,
|
||
}
|
||
}),
|
||
});
|
||
}
|
||
|
||
// Save publishing settings
|
||
if (activeTab === 'publishing') {
|
||
await fetchAPI('/v1/system/settings/content/publishing/save/', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
config: {
|
||
auto_publish_enabled: publishingSettings.autoPublishEnabled,
|
||
auto_sync_enabled: publishingSettings.autoSyncEnabled,
|
||
}
|
||
}),
|
||
});
|
||
}
|
||
|
||
toast.success('Settings saved successfully');
|
||
} catch (error: any) {
|
||
console.error('Error saving settings:', error);
|
||
toast.error(`Failed to save settings: ${error.message}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const tabTitles: Record<TabType, string> = {
|
||
content: 'Content Generation',
|
||
publishing: 'Publishing',
|
||
images: 'Image Settings',
|
||
};
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
||
<PageHeader
|
||
title={tabTitles[activeTab]}
|
||
description={
|
||
activeTab === 'content' ? 'Customize how your articles are written' :
|
||
activeTab === 'publishing' ? 'Configure automatic publishing settings' :
|
||
'Set up AI image generation preferences'
|
||
}
|
||
badge={{ icon: <BoxCubeIcon />, color: 'blue' }}
|
||
parent="Content Settings"
|
||
/>
|
||
|
||
{/* Tab Content */}
|
||
<div className="mt-6">
|
||
{/* Content Generation Tab */}
|
||
{activeTab === 'content' && (
|
||
<div className="space-y-6 max-w-4xl">
|
||
<Card className="p-6 border-l-4 border-l-brand-500">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Content Generation</h2>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">Customize how your articles are written</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<div>
|
||
<Label className="mb-2">Append to Every Prompt</Label>
|
||
<TextArea
|
||
value={contentSettings.appendToPrompt}
|
||
onChange={(value) => setContentSettings({ ...contentSettings, appendToPrompt: value })}
|
||
placeholder="Add custom instructions that will be included with every content generation request..."
|
||
rows={5}
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
This text will be appended to every AI prompt. Use it to enforce brand guidelines, tone, or specific requirements.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div>
|
||
<Label className="mb-2">Default Writing Tone</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: 'professional', label: 'Professional' },
|
||
{ value: 'conversational', label: 'Conversational' },
|
||
{ value: 'formal', label: 'Formal' },
|
||
{ value: 'casual', label: 'Casual' },
|
||
{ value: 'friendly', label: 'Friendly' },
|
||
]}
|
||
value={contentSettings.defaultTone}
|
||
onChange={(value) => setContentSettings({ ...contentSettings, defaultTone: value })}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-2">Default Article Length</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: 'short', label: 'Short (500-800 words)' },
|
||
{ value: 'medium', label: 'Medium (1000-1500 words)' },
|
||
{ value: 'long', label: 'Long (2000-3000 words)' },
|
||
{ value: 'comprehensive', label: 'Comprehensive (3000+ words)' },
|
||
]}
|
||
value={contentSettings.defaultLength}
|
||
onChange={(value) => setContentSettings({ ...contentSettings, defaultLength: value })}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Save Button */}
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="primary"
|
||
tone="brand"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||
>
|
||
{saving ? 'Saving...' : 'Save Settings'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Publishing Tab */}
|
||
{activeTab === 'publishing' && (
|
||
<div className="space-y-6 max-w-4xl">
|
||
<Card className="p-6 border-l-4 border-l-success-500">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||
<PaperPlaneIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Publishing to your sites</h2>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">Configure automatic publishing to your sites</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{/* Auto-Publish Setting */}
|
||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-start gap-3">
|
||
<Checkbox
|
||
checked={publishingSettings.autoPublishEnabled}
|
||
onChange={(checked) => setPublishingSettings({ ...publishingSettings, autoPublishEnabled: checked })}
|
||
/>
|
||
<div className="flex-1">
|
||
<Label className="font-medium text-gray-900 dark:text-white">
|
||
Automatic Publishing
|
||
</Label>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
Automatically publish articles to your site when they're finished and reviewed
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{publishingSettings.autoPublishEnabled && (
|
||
<div className="mt-4 p-3 bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg">
|
||
<p className="text-sm text-brand-800 dark:text-brand-200">
|
||
Articles will be published automatically once they pass review. You can still manually review them first if needed.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Auto-Sync Setting */}
|
||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-start gap-3">
|
||
<Checkbox
|
||
checked={publishingSettings.autoSyncEnabled}
|
||
onChange={(checked) => setPublishingSettings({ ...publishingSettings, autoSyncEnabled: checked })}
|
||
/>
|
||
<div className="flex-1">
|
||
<Label className="font-medium text-gray-900 dark:text-white">
|
||
Keep Content Updated
|
||
</Label>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
Automatically update articles on your site if you make changes here
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Save Button */}
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="primary"
|
||
tone="brand"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||
>
|
||
{saving ? 'Saving...' : 'Save Settings'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image Settings Tab */}
|
||
{activeTab === 'images' && (
|
||
<div className="space-y-6 max-w-4xl">
|
||
<Card className="p-6 border-l-4 border-l-info-500">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="p-2 bg-info-100 dark:bg-info-900/30 rounded-lg">
|
||
<ImageIcon className="w-5 h-5 text-info-600 dark:text-info-400" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Image Generation</h2>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">Configure how images are created for your articles</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{/* Row 1: Image Quality & Style */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div>
|
||
<Label className="mb-2">Image Quality</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: 'standard', label: 'Standard - Fast & economical (DALL·E 2)' },
|
||
{ value: 'premium', label: 'Premium - High quality (DALL·E 3)' },
|
||
{ value: 'best', label: 'Best - Highest quality (Runware)' },
|
||
]}
|
||
value={imageQuality}
|
||
onChange={(value) => setImageQuality(value as 'standard' | 'premium' | 'best')}
|
||
className="w-full"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Higher quality produces better images
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-2">Image Style</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: 'realistic', label: 'Realistic' },
|
||
{ value: 'artistic', label: 'Artistic' },
|
||
{ value: 'cartoon', label: 'Cartoon' },
|
||
]}
|
||
value={imageSettings.image_type}
|
||
onChange={(value) => setImageSettings({ ...imageSettings, image_type: value as any })}
|
||
className="w-full"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Choose the visual style that matches your brand
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Featured Image Size */}
|
||
<div>
|
||
<Label className="mb-2">Featured Image</Label>
|
||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-info-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>
|
||
<SelectDropdown
|
||
options={availableSizes}
|
||
value={imageSettings.featured_image_size}
|
||
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"
|
||
/>
|
||
</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={availableSizes}
|
||
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 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div>
|
||
<Label className="mb-2">Max In-Article Images</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: '1', label: '1 Image' },
|
||
{ 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"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-2">Image Format</Label>
|
||
<SelectDropdown
|
||
options={[
|
||
{ value: 'webp', label: 'WEBP (recommended)' },
|
||
{ value: 'jpg', label: 'JPG' },
|
||
{ value: 'png', label: 'PNG' },
|
||
]}
|
||
value={imageSettings.image_format}
|
||
onChange={(value) => setImageSettings({ ...imageSettings, image_format: value as any })}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Save Button */}
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="primary"
|
||
tone="brand"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||
>
|
||
{saving ? 'Saving...' : 'Save Settings'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|