AI AUtomtaion, Schudelign and publishign fromt and backe end refoactr
This commit is contained in:
@@ -113,7 +113,7 @@ const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
||||
|
||||
// Publisher Module - Lazy loaded
|
||||
const ContentCalendar = lazy(() => import("./pages/Publisher/ContentCalendar"));
|
||||
const PublishSettings = lazy(() => import("./pages/Publisher/PublishSettings"));
|
||||
// PublishSettings removed - now integrated into Site Settings > Automation tab
|
||||
|
||||
// Setup - Lazy loaded
|
||||
const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard"));
|
||||
@@ -203,10 +203,9 @@ export default function App() {
|
||||
<Route path="/automation/settings" element={<PipelineSettings />} />
|
||||
<Route path="/automation/run" element={<AutomationPage />} />
|
||||
|
||||
{/* Publisher Module - Content Calendar & Settings */}
|
||||
{/* Publisher Module - Content Calendar */}
|
||||
<Route path="/publisher" element={<Navigate to="/publisher/content-calendar" replace />} />
|
||||
<Route path="/publisher/content-calendar" element={<ContentCalendar />} />
|
||||
<Route path="/publisher/settings" element={<PublishSettings />} />
|
||||
|
||||
{/* Linker Module - Redirect dashboard to content */}
|
||||
<Route path="/linker" element={<Navigate to="/linker/content" replace />} />
|
||||
|
||||
@@ -104,8 +104,11 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Check if w-full is specified to expand to container width
|
||||
const isFullWidth = className.includes('w-full');
|
||||
|
||||
return (
|
||||
<div className={`relative flex-shrink-0 ${className}`}>
|
||||
<div className={`relative flex-shrink-0 ${isFullWidth ? 'w-full' : ''} ${className.replace('w-full', '').trim()}`}>
|
||||
{/* Trigger Button - styled like igny8-select-styled */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
@@ -113,9 +116,11 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ minWidth: `${estimatedMinWidth}px` }}
|
||||
className={`igny8-select-styled w-auto max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
|
||||
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm'
|
||||
style={isFullWidth ? undefined : { minWidth: `${estimatedMinWidth}px` }}
|
||||
className={`igny8-select-styled relative appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-8 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
|
||||
isFullWidth ? 'w-full' : 'w-auto'
|
||||
} ${
|
||||
className.includes('text-base') ? 'h-11 py-2.5 text-base' : className.includes('text-xs') ? 'h-8 py-1.5 text-xs' : 'h-9 py-2 text-sm'
|
||||
} ${
|
||||
isPlaceholder
|
||||
? "text-gray-400 dark:text-gray-400"
|
||||
@@ -126,9 +131,9 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
||||
: ""
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="block text-left truncate">{displayText}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<ChevronDownIcon className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`} />
|
||||
<span className="block text-left truncate pr-2">{displayText}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronDownIcon className={`h-4 w-4 text-gray-400 transition-transform flex-shrink-0 ${isOpen ? 'transform rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@ const AppSidebar: React.FC = () => {
|
||||
subItems: [
|
||||
{ name: "Content Review", path: "/writer/review" },
|
||||
{ name: "Publish / Schedule", path: "/writer/approved" },
|
||||
{ name: "Publish Settings", path: "/publisher/settings" },
|
||||
{ name: "Content Calendar", path: "/publisher/content-calendar" },
|
||||
],
|
||||
});
|
||||
|
||||
807
frontend/src/pages/Sites/AIAutomationSettings.tsx
Normal file
807
frontend/src/pages/Sites/AIAutomationSettings.tsx
Normal file
@@ -0,0 +1,807 @@
|
||||
/**
|
||||
* AI & Automation Settings Component
|
||||
* Per SETTINGS-CONSOLIDATION-PLAN.md
|
||||
*
|
||||
* Unified settings page for site automation, stage configuration, and publishing schedule.
|
||||
* Location: Site Settings > Automation tab
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import IconButton from '../../components/ui/button/IconButton';
|
||||
import Label from '../../components/form/Label';
|
||||
import InputField from '../../components/form/input/InputField';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Switch from '../../components/form/switch/Switch';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import {
|
||||
BoltIcon,
|
||||
CalendarIcon,
|
||||
Loader2Icon,
|
||||
SaveIcon,
|
||||
ClockIcon,
|
||||
PlayIcon,
|
||||
InfoIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
ImageIcon,
|
||||
} from '../../icons';
|
||||
import {
|
||||
getUnifiedSiteSettings,
|
||||
updateUnifiedSiteSettings,
|
||||
UnifiedSiteSettings,
|
||||
StageConfig,
|
||||
DAYS_OF_WEEK,
|
||||
FREQUENCY_OPTIONS,
|
||||
calculateTotalBudget,
|
||||
} from '../../services/unifiedSettings.api';
|
||||
|
||||
interface AIAutomationSettingsProps {
|
||||
siteId: number;
|
||||
}
|
||||
|
||||
// Tooltip component for inline help
|
||||
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="relative group inline-flex">
|
||||
{children}
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-900 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Image settings types
|
||||
interface ImageStyle {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function AIAutomationSettings({ siteId }: AIAutomationSettingsProps) {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settings, setSettings] = useState<UnifiedSiteSettings | null>(null);
|
||||
|
||||
// Image generation settings (from tenant-wide AI settings)
|
||||
const [imageSettingsLoading, setImageSettingsLoading] = useState(true);
|
||||
const [availableStyles, setAvailableStyles] = useState<ImageStyle[]>([
|
||||
{ value: 'photorealistic', label: 'Photorealistic' },
|
||||
{ value: 'illustration', label: 'Illustration' },
|
||||
{ value: '3d_render', label: '3D Render' },
|
||||
{ value: 'minimal_flat', label: 'Minimal / Flat' },
|
||||
{ value: 'artistic', label: 'Artistic' },
|
||||
{ value: 'cartoon', label: 'Cartoon' },
|
||||
]);
|
||||
const [selectedStyle, setSelectedStyle] = useState('photorealistic');
|
||||
const [maxImages, setMaxImages] = useState(4);
|
||||
const [maxAllowed, setMaxAllowed] = useState(8);
|
||||
const [featuredImageSize, setFeaturedImageSize] = useState('2560x1440');
|
||||
const [landscapeImageSize, setLandscapeImageSize] = useState('2560x1440');
|
||||
const [squareImageSize, setSquareImageSize] = useState('2048x2048');
|
||||
|
||||
// Load unified settings
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getUnifiedSiteSettings(siteId);
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load unified settings:', error);
|
||||
toast.error(`Failed to load settings: ${(error as Error).message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteId, toast]);
|
||||
|
||||
// Load image settings from tenant-wide AI settings API
|
||||
const loadImageSettings = useCallback(async () => {
|
||||
try {
|
||||
setImageSettingsLoading(true);
|
||||
const response = await fetchAPI('/v1/account/settings/ai/');
|
||||
if (response?.image_generation) {
|
||||
if (response.image_generation.styles) {
|
||||
setAvailableStyles(response.image_generation.styles);
|
||||
}
|
||||
setSelectedStyle(response.image_generation.selected_style || 'photorealistic');
|
||||
setMaxImages(response.image_generation.max_images ?? 4);
|
||||
setMaxAllowed(response.image_generation.max_allowed ?? 8);
|
||||
setFeaturedImageSize(response.image_generation.featured_image_size || '2560x1440');
|
||||
setLandscapeImageSize(response.image_generation.landscape_image_size || '2560x1440');
|
||||
setSquareImageSize(response.image_generation.square_image_size || '2048x2048');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load image settings:', error);
|
||||
} finally {
|
||||
setImageSettingsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadImageSettings();
|
||||
}, [loadSettings, loadImageSettings]);
|
||||
|
||||
// Save settings
|
||||
const handleSave = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Save unified settings
|
||||
const updated = await updateUnifiedSiteSettings(siteId, {
|
||||
automation: settings.automation,
|
||||
stages: settings.stages.map(s => ({
|
||||
number: s.number,
|
||||
enabled: s.enabled,
|
||||
batch_size: s.batch_size,
|
||||
per_run_limit: s.per_run_limit,
|
||||
use_testing: s.use_testing,
|
||||
budget_pct: s.budget_pct,
|
||||
})),
|
||||
delays: settings.delays,
|
||||
publishing: {
|
||||
auto_approval_enabled: settings.publishing.auto_approval_enabled,
|
||||
auto_publish_enabled: settings.publishing.auto_publish_enabled,
|
||||
publish_days: settings.publishing.publish_days,
|
||||
time_slots: settings.publishing.time_slots,
|
||||
},
|
||||
});
|
||||
setSettings(updated);
|
||||
|
||||
// Save image settings
|
||||
await fetchAPI('/v1/account/settings/ai/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
image_generation: {
|
||||
image_style: selectedStyle,
|
||||
max_images_per_article: maxImages,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
toast.success('Settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
toast.error(`Failed to save settings: ${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const handleReset = () => {
|
||||
loadSettings();
|
||||
loadImageSettings();
|
||||
toast.info('Settings reset to last saved values');
|
||||
};
|
||||
|
||||
// Update automation settings
|
||||
const updateAutomation = (updates: Partial<UnifiedSiteSettings['automation']>) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
automation: { ...settings.automation, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// Update stage configuration
|
||||
const updateStage = (stageNumber: number, updates: Partial<StageConfig>) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
stages: settings.stages.map(s =>
|
||||
s.number === stageNumber ? { ...s, ...updates } : s
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Update delays
|
||||
const updateDelays = (updates: Partial<UnifiedSiteSettings['delays']>) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
delays: { ...settings.delays, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// Update publishing settings
|
||||
const updatePublishing = (updates: Partial<UnifiedSiteSettings['publishing']>) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
publishing: { ...settings.publishing, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle day in publish_days
|
||||
const toggleDay = (day: string) => {
|
||||
if (!settings) return;
|
||||
const days = settings.publishing.publish_days;
|
||||
const newDays = days.includes(day)
|
||||
? days.filter(d => d !== day)
|
||||
: [...days, day];
|
||||
updatePublishing({ publish_days: newDays });
|
||||
};
|
||||
|
||||
// Add time slot
|
||||
const addTimeSlot = () => {
|
||||
if (!settings) return;
|
||||
const slots = settings.publishing.time_slots;
|
||||
let newSlot = '09:00';
|
||||
if (slots.length > 0) {
|
||||
const lastSlot = slots[slots.length - 1];
|
||||
const [hours, mins] = lastSlot.split(':').map(Number);
|
||||
const newHours = (hours + 3) % 24;
|
||||
newSlot = `${String(newHours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
updatePublishing({ time_slots: [...slots, newSlot] });
|
||||
};
|
||||
|
||||
// Remove time slot
|
||||
const removeTimeSlot = (index: number) => {
|
||||
if (!settings) return;
|
||||
const newSlots = settings.publishing.time_slots.filter((_, i) => i !== index);
|
||||
updatePublishing({ time_slots: newSlots });
|
||||
};
|
||||
|
||||
// Update time slot at index
|
||||
const updateTimeSlot = (index: number, newSlot: string) => {
|
||||
if (!settings) return;
|
||||
const slots = [...settings.publishing.time_slots];
|
||||
slots[index] = newSlot;
|
||||
updatePublishing({ time_slots: slots });
|
||||
};
|
||||
|
||||
// Clear all time slots
|
||||
const clearAllSlots = () => {
|
||||
if (!settings) return;
|
||||
updatePublishing({ time_slots: [] });
|
||||
};
|
||||
|
||||
if (loading || imageSettingsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-brand-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Failed to load settings. Please try again.</p>
|
||||
<Button variant="outline" onClick={loadSettings} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalBudget = calculateTotalBudget(settings.stages);
|
||||
const hasTestingEnabled = settings.stages.some(s => s.has_ai && s.use_testing);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Three Cards - Automation Schedule, Content Publishing, Image Generation */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Automation Schedule */}
|
||||
<Card className="p-5 border-l-4 border-l-brand-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<BoltIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Automation</h3>
|
||||
<p className="text-sm text-gray-500">Schedule runs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Enable Scheduled Runs</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={settings.automation.enabled}
|
||||
onChange={(checked) => updateAutomation({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-2">Frequency</Label>
|
||||
<SelectDropdown
|
||||
options={FREQUENCY_OPTIONS.map(f => ({ value: f.value, label: f.label }))}
|
||||
value={settings.automation.frequency}
|
||||
onChange={(value) => updateAutomation({ frequency: value as 'hourly' | 'daily' | 'weekly' })}
|
||||
disabled={!settings.automation.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2">Run Time</Label>
|
||||
<InputField
|
||||
type="time"
|
||||
value={settings.automation.time}
|
||||
onChange={(e) => updateAutomation({ time: e.target.value })}
|
||||
disabled={!settings.automation.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
{settings.automation.enabled && settings.automation.next_run_at ? (
|
||||
<span>Next run: {new Date(settings.automation.next_run_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<span>Runs {settings.automation.frequency} at {settings.automation.time}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Content Publishing */}
|
||||
<Card className="p-5 border-l-4 border-l-success-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<PlayIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Publishing</h3>
|
||||
<p className="text-sm text-gray-500">Auto-publish options</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Auto-Approve Content</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={settings.publishing.auto_approval_enabled}
|
||||
onChange={(checked) => updatePublishing({ auto_approval_enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Auto-Publish to Site</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={settings.publishing.auto_publish_enabled}
|
||||
onChange={(checked) => updatePublishing({ auto_publish_enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Mode Status */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">AI Mode:</span>
|
||||
<Badge tone={hasTestingEnabled ? 'warning' : 'success'} size="sm">
|
||||
{hasTestingEnabled ? 'Testing' : 'Live'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{hasTestingEnabled ? 'Using test models' : 'Production models'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Image Generation (Style & Count only) */}
|
||||
<Card className="p-5 border-l-4 border-l-purple-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Images</h3>
|
||||
<p className="text-sm text-gray-500">Style & count</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2">Style</Label>
|
||||
<SelectDropdown
|
||||
options={availableStyles.map(s => ({ value: s.value, label: s.label }))}
|
||||
value={selectedStyle}
|
||||
onChange={(value) => setSelectedStyle(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Images per Article</Label>
|
||||
<SelectDropdown
|
||||
options={Array.from({ length: maxAllowed }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: `${i + 1} image${i > 0 ? 's' : ''}`,
|
||||
}))}
|
||||
value={String(maxImages)}
|
||||
onChange={(value) => setMaxImages(parseInt(value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Sizes */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Featured</p>
|
||||
<p className="text-sm font-medium">{featuredImageSize}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Landscape</p>
|
||||
<p className="text-sm font-medium">{landscapeImageSize}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Square</p>
|
||||
<p className="text-sm font-medium">{squareImageSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Stage Configuration (2/3) + Schedule & Capacity (1/3) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left: Stage Configuration Matrix (2/3 width) */}
|
||||
<Card className="p-5 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<BoltIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Stage Configuration</h3>
|
||||
<p className="text-sm text-gray-500">Configure each automation stage</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={totalBudget <= 100 ? 'success' : 'danger'} size="sm">
|
||||
Budget: {totalBudget}%
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<InfoIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<span className="font-medium">Limit:</span> max items per run (0=all).
|
||||
<span className="font-medium ml-2">Budget:</span> credit allocation across AI stages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Table */}
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[40%]">Stage</th>
|
||||
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[10%]">
|
||||
<Tooltip text="Enable stage">On</Tooltip>
|
||||
</th>
|
||||
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
|
||||
<Tooltip text="Batch size">Batch</Tooltip>
|
||||
</th>
|
||||
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
|
||||
<Tooltip text="Per-run limit">Limit</Tooltip>
|
||||
</th>
|
||||
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[14%]">
|
||||
<Tooltip text="Test or Live AI">Model</Tooltip>
|
||||
</th>
|
||||
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
|
||||
<Tooltip text="Budget %">Budget</Tooltip>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settings.stages.map((stage) => (
|
||||
<tr
|
||||
key={stage.number}
|
||||
className={`border-b border-gray-100 dark:border-gray-800 ${!stage.enabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-sm">{stage.number}.</span>
|
||||
<span className={`text-sm ${stage.has_ai ? 'text-gray-900 dark:text-white' : 'text-gray-500'}`}>
|
||||
{stage.name}
|
||||
</span>
|
||||
{!stage.has_ai && <span className="text-xs text-gray-400">(local)</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
<Checkbox
|
||||
checked={stage.enabled}
|
||||
onChange={(checked) => updateStage(stage.number, { enabled: checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<InputField
|
||||
type="number"
|
||||
value={String(stage.batch_size)}
|
||||
onChange={(e) => updateStage(stage.number, { batch_size: parseInt(e.target.value) || 1 })}
|
||||
min="1"
|
||||
max="100"
|
||||
disabled={!stage.enabled}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<InputField
|
||||
type="number"
|
||||
value={String(stage.per_run_limit)}
|
||||
onChange={(e) => updateStage(stage.number, { per_run_limit: parseInt(e.target.value) || 0 })}
|
||||
min="0"
|
||||
max="1000"
|
||||
disabled={!stage.enabled}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
{stage.has_ai ? (
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'false', label: 'Live' },
|
||||
{ value: 'true', label: 'Test' },
|
||||
]}
|
||||
value={stage.use_testing ? 'true' : 'false'}
|
||||
onChange={(value) => updateStage(stage.number, { use_testing: value === 'true' })}
|
||||
disabled={!stage.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
{stage.has_ai ? (
|
||||
<InputField
|
||||
type="number"
|
||||
value={String(stage.budget_pct || 0)}
|
||||
onChange={(e) => updateStage(stage.number, { budget_pct: parseInt(e.target.value) || 0 })}
|
||||
min="0"
|
||||
max="100"
|
||||
disabled={!stage.enabled}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 text-center block">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Delays Row */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Between stages:</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
value={String(settings.delays.between_stage)}
|
||||
onChange={(e) => updateDelays({ between_stage: parseInt(e.target.value) || 0 })}
|
||||
min="0"
|
||||
max="60"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">sec</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Within stage:</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
value={String(settings.delays.within_stage)}
|
||||
onChange={(e) => updateDelays({ within_stage: parseInt(e.target.value) || 0 })}
|
||||
min="0"
|
||||
max="60"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right: Schedule + Capacity stacked */}
|
||||
<div className="space-y-6">
|
||||
{/* Schedule Card */}
|
||||
<Card className="p-5 border-l-4 border-l-pink-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-pink-100 dark:bg-pink-900/30 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-pink-600 dark:text-pink-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Schedule</h3>
|
||||
<p className="text-sm text-gray-500">Days and time slots</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days Selection */}
|
||||
<div className="mb-5">
|
||||
<Label className="mb-2">Publishing Days</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map((day) => (
|
||||
<Button
|
||||
key={day.value}
|
||||
variant={settings.publishing.publish_days.includes(day.value) ? 'primary' : 'outline'}
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className="w-10 h-10 p-0"
|
||||
>
|
||||
{day.label.charAt(0)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Time Slots</Label>
|
||||
{settings.publishing.time_slots.length > 0 && (
|
||||
<Button variant="ghost" tone="danger" size="sm" onClick={clearAllSlots}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{settings.publishing.time_slots.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||
No time slots. Add at least one.
|
||||
</div>
|
||||
) : (
|
||||
settings.publishing.time_slots.map((slot, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-8">#{index + 1}</span>
|
||||
<InputField
|
||||
type="time"
|
||||
value={slot}
|
||||
onChange={(e) => updateTimeSlot(index, e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<CloseIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
title="Remove"
|
||||
onClick={() => removeTimeSlot(index)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
onClick={addTimeSlot}
|
||||
className="mt-3"
|
||||
>
|
||||
Add Slot
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Calculated Capacity Card */}
|
||||
<Card className="p-5 border-l-4 border-l-amber-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Capacity</h3>
|
||||
<p className="text-sm text-gray-500">Calculated output</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-center py-4">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{settings.publishing.daily_capacity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Daily</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{settings.publishing.weekly_capacity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Weekly</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
~{settings.publishing.monthly_capacity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Monthly</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
<span>Based on {settings.publishing.publish_days.length} days × {settings.publishing.time_slots.length} slots</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
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>
|
||||
|
||||
{/* Help Cards - 3 cards explaining different sections */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* How Publishing Works */}
|
||||
<Card className="p-4 bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<InfoIcon className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-brand-800 dark:text-brand-200">
|
||||
<p className="font-medium mb-2">How Publishing Works</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-brand-700 dark:text-brand-300">
|
||||
<li>Content: Draft → Review → Approved → Published</li>
|
||||
<li>Auto-approval moves Review to Approved</li>
|
||||
<li>Auto-publish sends to WordPress</li>
|
||||
<li>Manual publish always available</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stage Configuration Explanation */}
|
||||
<Card className="p-4 bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<BoltIcon className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-purple-800 dark:text-purple-200">
|
||||
<p className="font-medium mb-2">Stage Configuration</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-purple-700 dark:text-purple-300">
|
||||
<li><strong>Batch:</strong> Items processed together</li>
|
||||
<li><strong>Limit:</strong> Max items per automation run</li>
|
||||
<li><strong>Model:</strong> Live (production) or Test mode</li>
|
||||
<li><strong>Budget:</strong> Credit allocation per stage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Schedule & Capacity Explanation */}
|
||||
<Card className="p-4 bg-pink-50 dark:bg-pink-900/20 border-pink-200 dark:border-pink-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-pink-600 dark:text-pink-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-pink-800 dark:text-pink-200">
|
||||
<p className="font-medium mb-2">Schedule & Capacity</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-pink-700 dark:text-pink-300">
|
||||
<li>Select days content can be published</li>
|
||||
<li>Add time slots for publishing</li>
|
||||
<li>Capacity = Days × Time Slots</li>
|
||||
<li>Content scheduled to next available slot</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import Badge from '../../components/ui/badge/Badge';
|
||||
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
|
||||
import SiteInfoBar from '../../components/common/SiteInfoBar';
|
||||
import AIAutomationSettings from './AIAutomationSettings';
|
||||
|
||||
export default function SiteSettings() {
|
||||
const { id: siteId } = useParams<{ id: string }>();
|
||||
@@ -51,9 +52,9 @@ export default function SiteSettings() {
|
||||
const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false);
|
||||
const siteSelectorRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Check for tab parameter in URL - content-types removed, redirects to integrations
|
||||
const initialTab = (searchParams.get('tab') as 'general' | 'ai-settings' | 'integrations') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations'>(initialTab);
|
||||
// Check for tab parameter in URL - ai-settings removed (content in Automation tab)
|
||||
const initialTab = (searchParams.get('tab') as 'general' | 'automation' | 'integrations') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'automation' | 'integrations'>(initialTab);
|
||||
|
||||
// Advanced Settings toggle
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
@@ -137,7 +138,7 @@ export default function SiteSettings() {
|
||||
useEffect(() => {
|
||||
// Update tab if URL parameter changes
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab && ['general', 'ai-settings', 'integrations'].includes(tab)) {
|
||||
if (tab && ['general', 'ai-settings', 'automation', 'integrations'].includes(tab)) {
|
||||
setActiveTab(tab as typeof activeTab);
|
||||
}
|
||||
// Handle legacy tab names - redirect content-types to integrations
|
||||
@@ -580,17 +581,17 @@ export default function SiteSettings() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('ai-settings');
|
||||
navigate(`/sites/${siteId}/settings?tab=ai-settings`, { replace: true });
|
||||
setActiveTab('automation');
|
||||
navigate(`/sites/${siteId}/settings?tab=automation`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'ai-settings'
|
||||
? 'border-success-500 text-success-600 dark:text-success-400'
|
||||
activeTab === 'automation'
|
||||
? 'border-purple-500 text-purple-600 dark:text-purple-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
startIcon={<BoltIcon className={`w-4 h-4 ${activeTab === 'ai-settings' ? 'text-success-500' : ''}`} />}
|
||||
startIcon={<CalendarIcon className={`w-4 h-4 ${activeTab === 'automation' ? 'text-purple-500' : ''}`} />}
|
||||
>
|
||||
AI Settings
|
||||
Automation
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -611,256 +612,19 @@ export default function SiteSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Settings Tab (merged content-generation + image-settings) */}
|
||||
{activeTab === 'ai-settings' && (
|
||||
<div className="space-y-6">
|
||||
{/* 3 Cards in a Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Content Settings */}
|
||||
<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 Settings</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Customize article writing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contentGenerationLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-brand-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2">Append to Prompt</Label>
|
||||
<TextArea
|
||||
value={contentGenerationSettings.appendToPrompt}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, appendToPrompt: value })}
|
||||
placeholder="Custom instructions..."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Appended to every AI prompt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">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={contentGenerationSettings.defaultTone}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultTone: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Article Length</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'short', label: 'Short (500-800)' },
|
||||
{ value: 'medium', label: 'Medium (1000-1500)' },
|
||||
{ value: 'long', label: 'Long (2000-3000)' },
|
||||
{ value: 'comprehensive', label: 'Comprehensive (3000+)' },
|
||||
]}
|
||||
value={contentGenerationSettings.defaultLength}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultLength: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Card 2: AI Parameters */}
|
||||
<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">
|
||||
<BoltIcon 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">AI Parameters</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Fine-tune content generation behavior</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiSettingsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-success-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Temperature Slider */}
|
||||
<div>
|
||||
<Label className="mb-2">Temperature</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-success-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More focused</span>
|
||||
<span>More creative</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-12 text-center font-medium text-gray-700 dark:text-gray-300">{temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens Dropdown */}
|
||||
<div className="max-w-xs">
|
||||
<Label className="mb-2">Max Tokens</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: '2048', label: '2,048 tokens' },
|
||||
{ value: '4096', label: '4,096 tokens' },
|
||||
{ value: '8192', label: '8,192 tokens' },
|
||||
{ value: '16384', label: '16,384 tokens' },
|
||||
]}
|
||||
value={String(maxTokens)}
|
||||
onChange={(value) => setMaxTokens(parseInt(value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum length of generated content. Higher values allow longer articles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Image Generation */}
|
||||
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-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">Quality & style</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiSettingsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-purple-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Quality Tier Dropdown */}
|
||||
<div>
|
||||
<Label className="mb-2">Quality</Label>
|
||||
<SelectDropdown
|
||||
options={qualityTiers.length > 0
|
||||
? qualityTiers.map(tier => ({
|
||||
value: tier.tier || tier.value,
|
||||
label: `${tier.label} (${tier.credits} credits)`
|
||||
}))
|
||||
: [
|
||||
{ value: 'basic', label: 'Basic (1 credit)' },
|
||||
{ value: 'quality', label: 'Quality (5 credits)' },
|
||||
{ value: 'premium', label: 'Premium (15 credits)' },
|
||||
]
|
||||
}
|
||||
value={selectedTier || 'quality'}
|
||||
onChange={(value) => setSelectedTier(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Style Dropdown */}
|
||||
<div>
|
||||
<Label className="mb-2">Style</Label>
|
||||
<SelectDropdown
|
||||
options={availableStyles.length > 0
|
||||
? availableStyles.map(style => ({ value: style.value, label: style.label }))
|
||||
: [
|
||||
{ value: 'photorealistic', label: 'Photorealistic' },
|
||||
{ value: 'illustration', label: 'Illustration' },
|
||||
{ value: '3d_render', label: '3D Render' },
|
||||
{ value: 'minimal_flat', label: 'Minimal / Flat' },
|
||||
{ value: 'artistic', label: 'Artistic' },
|
||||
{ value: 'cartoon', label: 'Cartoon' },
|
||||
]
|
||||
}
|
||||
value={selectedStyle}
|
||||
onChange={(value) => setSelectedStyle(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Images Per Article Dropdown */}
|
||||
<div>
|
||||
<Label className="mb-2">Images per Article</Label>
|
||||
<SelectDropdown
|
||||
options={Array.from({ length: maxAllowed || 8 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: `${i + 1} image${i > 0 ? 's' : ''}`,
|
||||
}))}
|
||||
value={String(maxImages || 4)}
|
||||
onChange={(value) => setMaxImages(parseInt(value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Sizes Display */}
|
||||
<div className="grid grid-cols-3 gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Featured Image</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{featuredImageSize}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Landscape</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{landscapeImageSize}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Square</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{squareImageSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
{/* End of 3-card grid */}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={async () => {
|
||||
await Promise.all([
|
||||
saveAISettings(),
|
||||
saveContentGenerationSettings(),
|
||||
]);
|
||||
}}
|
||||
disabled={aiSettingsSaving || contentGenerationSaving}
|
||||
startIcon={(aiSettingsSaving || contentGenerationSaving) ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{(aiSettingsSaving || contentGenerationSaving) ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Automation Tab - Unified AI & Automation Settings */}
|
||||
{activeTab === 'automation' && siteId && (
|
||||
<AIAutomationSettings siteId={Number(siteId)} />
|
||||
)}
|
||||
|
||||
{/*
|
||||
AI Settings Tab - REMOVED (Jan 2026)
|
||||
Reason: Text AI override options removed from user control.
|
||||
Image settings moved to Automation tab.
|
||||
Content generation settings (tone, length, append to prompt) to be removed in future.
|
||||
State and functions kept for backward compatibility but tab hidden from UI.
|
||||
*/}
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="space-y-6">
|
||||
{/* General Tab */}
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface AutomationConfig {
|
||||
stage_6_batch_size: number;
|
||||
within_stage_delay: number;
|
||||
between_stage_delay: number;
|
||||
// Per-run limits (0 = unlimited)
|
||||
max_keywords_per_run: number;
|
||||
max_clusters_per_run: number;
|
||||
max_ideas_per_run: number;
|
||||
max_tasks_per_run: number;
|
||||
max_content_per_run: number;
|
||||
max_images_per_run: number;
|
||||
max_approvals_per_run: number;
|
||||
max_credits_per_run: number;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
}
|
||||
|
||||
162
frontend/src/services/unifiedSettings.api.ts
Normal file
162
frontend/src/services/unifiedSettings.api.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Unified Settings API Service
|
||||
* Per SETTINGS-CONSOLIDATION-PLAN.md
|
||||
*
|
||||
* Consolidates AI & Automation settings into a single API endpoint.
|
||||
*/
|
||||
import { fetchAPI } from './api';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// TYPES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export interface StageConfig {
|
||||
number: number;
|
||||
name: string;
|
||||
has_ai: boolean;
|
||||
enabled: boolean;
|
||||
batch_size: number;
|
||||
per_run_limit: number;
|
||||
use_testing?: boolean;
|
||||
budget_pct?: number;
|
||||
}
|
||||
|
||||
export interface AvailableModel {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
export interface UnifiedSiteSettings {
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
automation: {
|
||||
enabled: boolean;
|
||||
frequency: 'hourly' | 'daily' | 'weekly';
|
||||
time: string; // HH:MM format
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
};
|
||||
stages: StageConfig[];
|
||||
delays: {
|
||||
within_stage: number;
|
||||
between_stage: number;
|
||||
};
|
||||
publishing: {
|
||||
auto_approval_enabled: boolean;
|
||||
auto_publish_enabled: boolean;
|
||||
publish_days: string[]; // ['mon', 'tue', ...]
|
||||
time_slots: string[]; // ['09:00', '14:00', ...]
|
||||
daily_capacity: number;
|
||||
weekly_capacity: number;
|
||||
monthly_capacity: number;
|
||||
};
|
||||
available_models: {
|
||||
text: {
|
||||
testing: AvailableModel | null;
|
||||
live: AvailableModel | null;
|
||||
};
|
||||
image: {
|
||||
testing: AvailableModel | null;
|
||||
live: AvailableModel | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateUnifiedSettingsRequest {
|
||||
automation?: {
|
||||
enabled?: boolean;
|
||||
frequency?: 'hourly' | 'daily' | 'weekly';
|
||||
time?: string;
|
||||
};
|
||||
stages?: Array<{
|
||||
number: number;
|
||||
enabled?: boolean;
|
||||
batch_size?: number;
|
||||
per_run_limit?: number;
|
||||
use_testing?: boolean;
|
||||
budget_pct?: number;
|
||||
}>;
|
||||
delays?: {
|
||||
within_stage?: number;
|
||||
between_stage?: number;
|
||||
};
|
||||
publishing?: {
|
||||
auto_approval_enabled?: boolean;
|
||||
auto_publish_enabled?: boolean;
|
||||
publish_days?: string[];
|
||||
time_slots?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// API FUNCTIONS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get unified site settings (AI & Automation consolidated)
|
||||
*/
|
||||
export async function getUnifiedSiteSettings(siteId: number): Promise<UnifiedSiteSettings> {
|
||||
const response = await fetchAPI(`/v1/integration/sites/${siteId}/unified-settings/`);
|
||||
return response.data || response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unified site settings
|
||||
*/
|
||||
export async function updateUnifiedSiteSettings(
|
||||
siteId: number,
|
||||
data: UpdateUnifiedSettingsRequest
|
||||
): Promise<UnifiedSiteSettings> {
|
||||
const response = await fetchAPI(`/v1/integration/sites/${siteId}/unified-settings/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return response.data || response;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Days of week for publishing schedule
|
||||
*/
|
||||
export const DAYS_OF_WEEK = [
|
||||
{ value: 'mon', label: 'Mon' },
|
||||
{ value: 'tue', label: 'Tue' },
|
||||
{ value: 'wed', label: 'Wed' },
|
||||
{ value: 'thu', label: 'Thu' },
|
||||
{ value: 'fri', label: 'Fri' },
|
||||
{ value: 'sat', label: 'Sat' },
|
||||
{ value: 'sun', label: 'Sun' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Frequency options for automation
|
||||
*/
|
||||
export const FREQUENCY_OPTIONS = [
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
export function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':');
|
||||
const h = parseInt(hours);
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h;
|
||||
return `${displayHour}:${minutes} ${ampm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total budget percentage from stages
|
||||
*/
|
||||
export function calculateTotalBudget(stages: StageConfig[]): number {
|
||||
return stages
|
||||
.filter(s => s.has_ai && s.budget_pct)
|
||||
.reduce((sum, s) => sum + (s.budget_pct || 0), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user