AI AUtomtaion, Schudelign and publishign fromt and backe end refoactr

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-17 15:52:46 +00:00
parent 0435a5cf70
commit d3b3e1c0d4
34 changed files with 4715 additions and 375 deletions

View File

@@ -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 />} />

View File

@@ -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>

View File

@@ -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" },
],
});

View 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>
);
}

View File

@@ -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 */}

View File

@@ -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;
}

View 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);
}