NAVIGATION_REFACTOR COMPLETED
This commit is contained in:
@@ -48,6 +48,8 @@ const Approved = lazy(() => import("./pages/Writer/Approved"));
|
||||
|
||||
// Automation Module - Lazy loaded
|
||||
const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage"));
|
||||
const AutomationOverview = lazy(() => import("./pages/Automation/AutomationOverview"));
|
||||
const PipelineSettings = lazy(() => import("./pages/Automation/PipelineSettings"));
|
||||
|
||||
// Linker Module - Lazy loaded
|
||||
const LinkerContentList = lazy(() => import("./pages/Linker/ContentList"));
|
||||
@@ -110,6 +112,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"));
|
||||
|
||||
// Setup - Lazy loaded
|
||||
const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard"));
|
||||
@@ -193,11 +196,15 @@ export default function App() {
|
||||
<Route path="/writer/published" element={<Navigate to="/writer/approved" replace />} />
|
||||
|
||||
{/* Automation Module */}
|
||||
<Route path="/automation" element={<AutomationPage />} />
|
||||
<Route path="/automation" element={<Navigate to="/automation/overview" replace />} />
|
||||
<Route path="/automation/overview" element={<AutomationOverview />} />
|
||||
<Route path="/automation/settings" element={<PipelineSettings />} />
|
||||
<Route path="/automation/run" element={<AutomationPage />} />
|
||||
|
||||
{/* Publisher Module - Content Calendar */}
|
||||
{/* Publisher Module - Content Calendar & Settings */}
|
||||
<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 />} />
|
||||
|
||||
@@ -111,7 +111,7 @@ export const createClustersPageConfig = (
|
||||
render: (value: string, row: Cluster) => (
|
||||
<Link
|
||||
to={`/planner/clusters/${row.id}`}
|
||||
className="text-base font-light text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
|
||||
@@ -109,7 +109,7 @@ export const createIdeasPageConfig = (
|
||||
toggleContentKey: 'description', // Use description field for toggle content
|
||||
toggleContentLabel: 'Content Outline', // Label for expanded content
|
||||
render: (value: string) => (
|
||||
<span className="text-gray-800 dark:text-white text-base font-light">{value}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{value}</span>
|
||||
),
|
||||
},
|
||||
// Sector column - only show when viewing all sectors
|
||||
|
||||
@@ -153,7 +153,7 @@ export const createKeywordsPageConfig = (
|
||||
sortField: 'seed_keyword__keyword',
|
||||
width: '300px',
|
||||
render: (value: string) => (
|
||||
<span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{value || '-'}
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -113,7 +113,7 @@ export const createTasksPageConfig = (
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-light text-gray-900 dark:text-white">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{displayTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const titleColumn = {
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
className: 'text-sm',
|
||||
};
|
||||
|
||||
export const keywordColumn = {
|
||||
|
||||
@@ -148,28 +148,35 @@ const AppSidebar: React.FC = () => {
|
||||
{ name: "Content Queue", path: "/writer/tasks" },
|
||||
{ name: "Content Drafts", path: "/writer/content" },
|
||||
{ name: "Content Images", path: "/writer/images" },
|
||||
{ name: "Content Review", path: "/writer/review" },
|
||||
{ name: "Content Approved", path: "/writer/approved" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Automation if enabled (no dropdown - single page)
|
||||
// Add Publisher (after Writer) - always visible
|
||||
workflowItems.push({
|
||||
icon: <CalendarIcon />,
|
||||
name: "Publisher",
|
||||
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" },
|
||||
],
|
||||
});
|
||||
|
||||
// Add Automation if enabled (with dropdown)
|
||||
if (isModuleEnabled('automation')) {
|
||||
workflowItems.push({
|
||||
icon: <BoltIcon />,
|
||||
name: "Automation",
|
||||
path: "/automation",
|
||||
subItems: [
|
||||
{ name: "Overview", path: "/automation/overview" },
|
||||
{ name: "Settings", path: "/automation/settings" },
|
||||
{ name: "Run Now (Manual)", path: "/automation/run" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Content Calendar (Publisher) - always visible
|
||||
workflowItems.push({
|
||||
icon: <CalendarIcon />,
|
||||
name: "Content Calendar",
|
||||
path: "/publisher/content-calendar",
|
||||
});
|
||||
|
||||
// Linker and Optimizer removed - not active modules
|
||||
|
||||
return [
|
||||
@@ -195,19 +202,10 @@ const AppSidebar: React.FC = () => {
|
||||
{
|
||||
label: "ACCOUNT",
|
||||
items: [
|
||||
{
|
||||
icon: <Bell className="w-5 h-5" />,
|
||||
name: "Notifications",
|
||||
path: "/account/notifications",
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Account Settings",
|
||||
subItems: [
|
||||
{ name: "Account", path: "/account/settings" },
|
||||
{ name: "Profile", path: "/account/settings/profile" },
|
||||
{ name: "Team", path: "/account/settings/team" },
|
||||
],
|
||||
path: "/account/settings", // Single page, no sub-items
|
||||
},
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
@@ -230,6 +228,11 @@ const AppSidebar: React.FC = () => {
|
||||
{
|
||||
label: "HELP",
|
||||
items: [
|
||||
{
|
||||
icon: <Bell className="w-5 h-5" />,
|
||||
name: "Notifications",
|
||||
path: "/account/notifications",
|
||||
},
|
||||
{
|
||||
icon: <DocsIcon />,
|
||||
name: "Help & Docs",
|
||||
@@ -503,7 +506,7 @@ const AppSidebar: React.FC = () => {
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar mt-[50px]">
|
||||
<nav>
|
||||
<div className="flex flex-col gap-1">
|
||||
{allSections.map((section, sectionIndex) => (
|
||||
|
||||
289
frontend/src/pages/Automation/AutomationOverview.tsx
Normal file
289
frontend/src/pages/Automation/AutomationOverview.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Automation Overview Page
|
||||
* Comprehensive dashboard showing automation status, metrics, cost estimation, and run history
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { automationService } from '../../services/automationService';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
} from '../../services/api';
|
||||
import RunHistory from '../../components/Automation/RunHistory';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import {
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
BoltIcon,
|
||||
} from '../../icons';
|
||||
|
||||
const AutomationOverview: React.FC = () => {
|
||||
const { activeSite } = useSiteStore();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [estimate, setEstimate] = useState<any>(null);
|
||||
|
||||
// Load metrics for the 5 metric cards
|
||||
const loadMetrics = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
const [
|
||||
keywordsTotalRes, keywordsNewRes, keywordsMappedRes,
|
||||
clustersTotalRes, clustersNewRes, clustersMappedRes,
|
||||
ideasTotalRes, ideasNewRes, ideasQueuedRes, ideasCompletedRes,
|
||||
tasksTotalRes,
|
||||
contentTotalRes, contentDraftRes, contentReviewRes, contentPublishedRes,
|
||||
contentNotPublishedRes, contentScheduledRes,
|
||||
imagesTotalRes, imagesPendingRes,
|
||||
] = await Promise.all([
|
||||
fetchKeywords({ page_size: 1, site_id: activeSite.id }),
|
||||
fetchKeywords({ page_size: 1, site_id: activeSite.id, status: 'new' }),
|
||||
fetchKeywords({ page_size: 1, site_id: activeSite.id, status: 'mapped' }),
|
||||
fetchClusters({ page_size: 1, site_id: activeSite.id }),
|
||||
fetchClusters({ page_size: 1, site_id: activeSite.id, status: 'new' }),
|
||||
fetchClusters({ page_size: 1, site_id: activeSite.id, status: 'mapped' }),
|
||||
fetchContentIdeas({ page_size: 1, site_id: activeSite.id }),
|
||||
fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'new' }),
|
||||
fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'queued' }),
|
||||
fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'completed' }),
|
||||
fetchTasks({ page_size: 1, site_id: activeSite.id }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id, status: 'draft' }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id, status: 'review' }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id, status__in: 'approved,published' }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id, status: 'approved' }),
|
||||
fetchContent({ page_size: 1, site_id: activeSite.id, status: 'approved' }),
|
||||
fetchImages({ page_size: 1 }),
|
||||
fetchImages({ page_size: 1, status: 'pending' }),
|
||||
]);
|
||||
|
||||
setMetrics({
|
||||
keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 },
|
||||
clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 },
|
||||
ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 },
|
||||
tasks: { total: tasksTotalRes.count || 0 },
|
||||
content: {
|
||||
total: contentTotalRes.count || 0,
|
||||
draft: contentDraftRes.count || 0,
|
||||
review: contentReviewRes.count || 0,
|
||||
published: contentPublishedRes.count || 0,
|
||||
not_published: contentNotPublishedRes.count || 0,
|
||||
scheduled: contentScheduledRes.count || 0,
|
||||
},
|
||||
images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch metrics for automation overview', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Load cost estimate
|
||||
const loadEstimate = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
const estimateData = await automationService.estimate(activeSite.id);
|
||||
setEstimate(estimateData);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch cost estimate', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([loadMetrics(), loadEstimate()]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (activeSite) {
|
||||
loadData();
|
||||
}
|
||||
}, [activeSite]);
|
||||
|
||||
// Helper to render metric rows
|
||||
const renderMetricRow = (items: Array<{ label: string; value: number; colorCls: string }>) => {
|
||||
return (
|
||||
<div className="flex justify-between text-xs mt-2">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-baseline gap-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">{item.label}</span>
|
||||
<span className={`font-semibold ${item.colorCls}`}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site to view automation overview.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading automation overview...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Automation Overview" description="Comprehensive automation dashboard" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Automation Overview"
|
||||
breadcrumb="Automation / Overview"
|
||||
description="Comprehensive automation dashboard with metrics, cost estimation, and run history"
|
||||
/>
|
||||
|
||||
{/* Metrics Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{/* Keywords */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ListIcon className="size-4 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Keywords</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-brand-600">{metrics?.keywords?.total || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMetricRow([
|
||||
{ label: 'New:', value: metrics?.keywords?.new || 0, colorCls: 'text-brand-600' },
|
||||
{ label: 'Mapped:', value: metrics?.keywords?.mapped || 0, colorCls: 'text-brand-600' },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{/* Clusters */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<GroupIcon className="size-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Clusters</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-purple-600">{metrics?.clusters?.total || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMetricRow([
|
||||
{ label: 'New:', value: metrics?.clusters?.new || 0, colorCls: 'text-purple-600' },
|
||||
{ label: 'Mapped:', value: metrics?.clusters?.mapped || 0, colorCls: 'text-purple-600' },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{/* Ideas */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<BoltIcon className="size-4 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Ideas</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-warning-600">{metrics?.ideas?.total || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMetricRow([
|
||||
{ label: 'New:', value: metrics?.ideas?.new || 0, colorCls: 'text-warning-600' },
|
||||
{ label: 'Queued:', value: metrics?.ideas?.queued || 0, colorCls: 'text-warning-600' },
|
||||
{ label: 'Done:', value: metrics?.ideas?.completed || 0, colorCls: 'text-warning-600' },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<FileTextIcon className="size-4 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Content</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-success-600">{metrics?.content?.total || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMetricRow([
|
||||
{ label: 'Draft:', value: metrics?.content?.draft || 0, colorCls: 'text-success-600' },
|
||||
{ label: 'Review:', value: metrics?.content?.review || 0, colorCls: 'text-success-600' },
|
||||
{ label: 'Publish:', value: metrics?.content?.published || 0, colorCls: 'text-success-600' },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center">
|
||||
<FileIcon className="size-4 text-info-600 dark:text-info-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Images</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-info-600">{metrics?.images?.total || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMetricRow([
|
||||
{ label: 'Pending:', value: metrics?.images?.pending || 0, colorCls: 'text-info-600' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Estimation Card */}
|
||||
{estimate && (
|
||||
<ComponentCard
|
||||
title="Ready to Process"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Estimated Items to Process: <span className="text-lg font-bold text-brand-600">{estimate.estimated_credits || 0}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Current Balance: <span className="text-lg font-bold text-success-600">{estimate.current_balance || 0}</span> credits
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status: {estimate.sufficient ? (
|
||||
<span className="text-success-600 font-bold">✓ Sufficient credits</span>
|
||||
) : (
|
||||
<span className="text-danger-600 font-bold">⚠ Insufficient credits</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
{activeSite && <RunHistory siteId={activeSite.id} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomationOverview;
|
||||
@@ -15,14 +15,11 @@ import {
|
||||
fetchImages,
|
||||
} from '../../services/api';
|
||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
import RunHistory from '../../components/Automation/RunHistory';
|
||||
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCardV2';
|
||||
import GlobalProgressBar, { getProcessedFromResult } from '../../components/Automation/GlobalProgressBar';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import {
|
||||
@@ -67,7 +64,6 @@ const AutomationPage: React.FC = () => {
|
||||
const [currentRun, setCurrentRun] = useState<AutomationRun | null>(null);
|
||||
const [pipelineOverview, setPipelineOverview] = useState<PipelineStage[]>([]);
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [showProcessingCard, setShowProcessingCard] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null);
|
||||
@@ -443,23 +439,6 @@ const AutomationPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async (newConfig: Partial<AutomationConfig>) => {
|
||||
if (!activeSite) return;
|
||||
try {
|
||||
await automationService.updateConfig(activeSite.id, newConfig);
|
||||
toast.success('Configuration saved');
|
||||
setShowConfigModal(false);
|
||||
// Optimistically update config locally and refresh data
|
||||
setConfig((prev) => ({ ...(prev as AutomationConfig), ...newConfig } as AutomationConfig));
|
||||
await loadPipelineOverview();
|
||||
await loadMetrics();
|
||||
await loadCurrentRun();
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
toast.error('Failed to save configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishAllWithoutReview = async () => {
|
||||
if (!activeSite) return;
|
||||
if (!confirm('Publish all content without review? This cannot be undone.')) return;
|
||||
@@ -640,13 +619,13 @@ const AutomationPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowConfigModal(true)}
|
||||
onClick={() => window.location.href = '/automation/settings'}
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
className="!border-white !text-white hover:!bg-white hover:!text-brand-700"
|
||||
>
|
||||
Configure
|
||||
Pipeline Settings
|
||||
</Button>
|
||||
{currentRun?.status === 'running' && (
|
||||
<Button
|
||||
@@ -687,179 +666,6 @@ const AutomationPage: React.FC = () => {
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Metrics Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{/* Keywords */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ListIcon className="size-4 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Keywords</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(1);
|
||||
const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-brand-600">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(1);
|
||||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[0]?.counts?.new ?? metrics?.keywords?.new ?? 0;
|
||||
const mapped = res?.mapped ?? pipelineOverview[0]?.counts?.mapped ?? metrics?.keywords?.mapped ?? 0;
|
||||
return (
|
||||
renderMetricRow([
|
||||
{ label: 'New:', value: newCount, colorCls: 'text-brand-600' },
|
||||
{ label: 'Mapped:', value: mapped, colorCls: 'text-brand-600' },
|
||||
])
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Clusters */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<GroupIcon className="size-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Clusters</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(2);
|
||||
const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-purple-600">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(2);
|
||||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[1]?.counts?.new ?? metrics?.clusters?.new ?? 0;
|
||||
const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0;
|
||||
return (
|
||||
renderMetricRow([
|
||||
{ label: 'New:', value: newCount, colorCls: 'text-purple-600' },
|
||||
{ label: 'Mapped:', value: mapped, colorCls: 'text-purple-600' },
|
||||
])
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Ideas */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<BoltIcon className="size-4 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Ideas</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(3);
|
||||
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-warning-600">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(3);
|
||||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[2]?.counts?.new ?? metrics?.ideas?.new ?? 0;
|
||||
const queued = res?.queued ?? pipelineOverview[2]?.counts?.queued ?? metrics?.ideas?.queued ?? 0;
|
||||
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
|
||||
return (
|
||||
renderMetricRow([
|
||||
{ label: 'New:', value: newCount, colorCls: 'text-warning-600' },
|
||||
{ label: 'Queued:', value: queued, colorCls: 'text-warning-600' },
|
||||
{ label: 'Completed:', value: completed, colorCls: 'text-warning-600' },
|
||||
])
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<FileTextIcon className="size-4 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Content</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(4);
|
||||
const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-success-600">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(4);
|
||||
const draft = res?.draft ?? res?.drafts ?? pipelineOverview[3]?.counts?.draft ?? metrics?.content?.draft ?? 0;
|
||||
const review = res?.review ?? res?.in_review ?? pipelineOverview[3]?.counts?.review ?? metrics?.content?.review ?? 0;
|
||||
const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0;
|
||||
return (
|
||||
renderMetricRow([
|
||||
{ label: 'Draft:', value: draft, colorCls: 'text-success-600' },
|
||||
{ label: 'Review:', value: review, colorCls: 'text-success-600' },
|
||||
{ label: 'Publish:', value: publish, colorCls: 'text-success-600' },
|
||||
])
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center">
|
||||
<FileIcon className="size-4 text-info-600 dark:text-info-400" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">Images</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(6);
|
||||
const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-info-600">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(6); // stage 6 is Image Prompts -> Images
|
||||
if (res && typeof res === 'object') {
|
||||
const entries = Object.entries(res);
|
||||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' }));
|
||||
return renderMetricRow(items);
|
||||
}
|
||||
const counts = pipelineOverview[5]?.counts ?? metrics?.images ?? null;
|
||||
if (counts && typeof counts === 'object') {
|
||||
const entries = Object.entries(counts);
|
||||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' }));
|
||||
return renderMetricRow(items);
|
||||
}
|
||||
return renderMetricRow([
|
||||
{ label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-info-600' },
|
||||
]);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Progress Bar - Shows full pipeline progress during automation run */}
|
||||
{currentRun && (currentRun.status === 'running' || currentRun.status === 'paused') && (
|
||||
<GlobalProgressBar
|
||||
@@ -1271,7 +1077,7 @@ const AutomationPage: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Scheduled summary card - Same layout as Stage cards */}
|
||||
{/* Stage 8 card - Scheduled summary card - Same layout as Stage cards */}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900 border-l-[5px] border-l-success-500">
|
||||
{/* Header Row - Icon, Label on left; Function Name on right */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -1279,10 +1085,10 @@ const AutomationPage: React.FC = () => {
|
||||
<div className="size-8 rounded-lg bg-gradient-to-br from-success-500 to-success-600 flex items-center justify-center shadow-md flex-shrink-0">
|
||||
<ClockIcon className="size-4 text-white" />
|
||||
</div>
|
||||
<span className="text-base font-bold text-gray-900 dark:text-white">Scheduled</span>
|
||||
<span className="text-base font-bold text-gray-900 dark:text-white">Stage 8</span>
|
||||
</div>
|
||||
{/* Stage Function Name - Right side, larger font */}
|
||||
<div className="text-sm font-bold text-success-600 dark:text-success-400">Ready to Publish</div>
|
||||
<div className="text-sm font-bold text-success-600 dark:text-success-400">Approved → Scheduled</div>
|
||||
</div>
|
||||
|
||||
{/* Single Row: Pending & Scheduled */}
|
||||
@@ -1308,14 +1114,6 @@ const AutomationPage: React.FC = () => {
|
||||
|
||||
{/* Activity Log */}
|
||||
{currentRun && <ActivityLog runId={currentRun.run_id} />}
|
||||
|
||||
{/* Run History */}
|
||||
<RunHistory siteId={activeSite.id} />
|
||||
|
||||
{/* Config Modal */}
|
||||
{showConfigModal && config && (
|
||||
<ConfigModal config={config} onSave={handleSaveConfig} onCancel={() => setShowConfigModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
399
frontend/src/pages/Automation/PipelineSettings.tsx
Normal file
399
frontend/src/pages/Automation/PipelineSettings.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Automation Pipeline Settings Page
|
||||
* Configure 7-stage automation pipeline (extracted from ConfigModal)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import Select from '../../components/form/Select';
|
||||
import InputField from '../../components/form/input/InputField';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { automationService, AutomationConfig } from '../../services/automationService';
|
||||
import { Loader2Icon } from '../../icons';
|
||||
|
||||
export default function PipelineSettings() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { activeSite } = useSiteStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [config, setConfig] = useState<AutomationConfig | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<AutomationConfig>>({});
|
||||
|
||||
// Load config for active site
|
||||
const loadConfig = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const configData = await automationService.getConfig(activeSite.id);
|
||||
setConfig(configData);
|
||||
setFormData({
|
||||
is_enabled: configData.is_enabled,
|
||||
frequency: configData.frequency,
|
||||
scheduled_time: configData.scheduled_time,
|
||||
stage_1_enabled: configData.stage_1_enabled ?? true,
|
||||
stage_2_enabled: configData.stage_2_enabled ?? true,
|
||||
stage_3_enabled: configData.stage_3_enabled ?? true,
|
||||
stage_4_enabled: configData.stage_4_enabled ?? true,
|
||||
stage_5_enabled: configData.stage_5_enabled ?? true,
|
||||
stage_6_enabled: configData.stage_6_enabled ?? true,
|
||||
stage_7_enabled: configData.stage_7_enabled ?? true,
|
||||
stage_1_batch_size: configData.stage_1_batch_size,
|
||||
stage_2_batch_size: configData.stage_2_batch_size,
|
||||
stage_3_batch_size: configData.stage_3_batch_size,
|
||||
stage_4_batch_size: configData.stage_4_batch_size,
|
||||
stage_5_batch_size: configData.stage_5_batch_size,
|
||||
stage_6_batch_size: configData.stage_6_batch_size,
|
||||
within_stage_delay: configData.within_stage_delay || 3,
|
||||
between_stage_delay: configData.between_stage_delay || 5,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load pipeline config:', error);
|
||||
toast.error('Failed to load pipeline configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save config
|
||||
const saveConfig = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
within_stage_delay: formData.within_stage_delay || 3,
|
||||
between_stage_delay: formData.between_stage_delay || 5,
|
||||
};
|
||||
await automationService.updateConfig(activeSite.id, dataToSave);
|
||||
// Reload config after save
|
||||
await loadConfig();
|
||||
toast.success('Pipeline configuration saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save pipeline config:', error);
|
||||
toast.error('Failed to save pipeline configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load config on mount and when active site changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [activeSite]);
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site to configure pipeline settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin mx-auto mb-3 text-brand-600" />
|
||||
<p>Loading pipeline configuration...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Pipeline Settings" description="Configure automation pipeline" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Pipeline Settings"
|
||||
breadcrumb="Automation / Pipeline Settings"
|
||||
description="Configure 7-stage automation pipeline processing"
|
||||
/>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); saveConfig(); }}>
|
||||
{/* Schedule Configuration */}
|
||||
<Card className="p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Schedule Configuration</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable/Disable */}
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Enable Scheduled Automation"
|
||||
checked={formData.is_enabled || false}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, is_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 ml-6">
|
||||
When enabled, automation will run on the configured schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-1">Frequency</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly (Mondays)' },
|
||||
{ value: 'monthly', label: 'Monthly (1st of month)' },
|
||||
]}
|
||||
defaultValue={formData.frequency || 'daily'}
|
||||
onChange={(val) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
frequency: val as 'daily' | 'weekly' | 'monthly',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Time */}
|
||||
<div>
|
||||
<InputField
|
||||
label="Scheduled Time"
|
||||
type="time"
|
||||
value={formData.scheduled_time || '02:00'}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, scheduled_time: e.target.value })
|
||||
}
|
||||
hint="Time of day to run automation (24-hour format)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stage Processing Toggles */}
|
||||
<Card className="p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">Stage Processing</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Enable or disable individual stages. Disabled stages will be skipped during automation.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Checkbox
|
||||
label="Stage 1: Keywords → Clusters"
|
||||
checked={formData.stage_1_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_1_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 2: Clusters → Ideas"
|
||||
checked={formData.stage_2_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_2_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 3: Ideas → Tasks"
|
||||
checked={formData.stage_3_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_3_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 4: Tasks → Content"
|
||||
checked={formData.stage_4_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_4_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 5: Content → Image Prompts"
|
||||
checked={formData.stage_5_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_5_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 6: Image Prompts → Images"
|
||||
checked={formData.stage_6_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_6_enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Stage 7: In Review → Approved"
|
||||
checked={formData.stage_7_enabled ?? true}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, stage_7_enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Batch Sizes */}
|
||||
<Card className="p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">Batch Sizes</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Configure how many items to process in each stage
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Stage 1: Keywords → Clusters"
|
||||
type="number"
|
||||
value={formData.stage_1_batch_size || 20}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_1_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Stage 2: Clusters → Ideas"
|
||||
type="number"
|
||||
value={formData.stage_2_batch_size || 1}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_2_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Stage 3: Ideas → Tasks"
|
||||
type="number"
|
||||
value={formData.stage_3_batch_size || 20}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_3_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Stage 4: Tasks → Content"
|
||||
type="number"
|
||||
value={formData.stage_4_batch_size || 1}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_4_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Stage 5: Content → Image Prompts"
|
||||
type="number"
|
||||
value={formData.stage_5_batch_size || 1}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_5_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Stage 6: Image Prompts → Images"
|
||||
type="number"
|
||||
value={formData.stage_6_batch_size || 1}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
stage_6_batch_size: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI Request Delays */}
|
||||
<Card className="p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">AI Request Delays</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Configure delays to prevent rate limiting and manage API load
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Within-Stage Delay (seconds)"
|
||||
type="number"
|
||||
value={formData.within_stage_delay || 3}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
within_stage_delay: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="0"
|
||||
max="30"
|
||||
hint="Delay between batches within a stage"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Between-Stage Delay (seconds)"
|
||||
type="number"
|
||||
value={formData.between_stage_delay || 5}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
between_stage_delay: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
min="0"
|
||||
max="60"
|
||||
hint="Delay between stage transitions"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate('/automation/overview')}
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
380
frontend/src/pages/Publisher/PublishSettings.tsx
Normal file
380
frontend/src/pages/Publisher/PublishSettings.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Publisher Settings Page
|
||||
* Configure automatic approval, publishing limits, and scheduling
|
||||
* Uses store-based activeSite instead of URL-based siteId (BUG FIX)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
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 Switch from '../../components/form/switch/Switch';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { BoltIcon, LayersIcon, CalendarIcon, InfoIcon, CloseIcon, PlusIcon, Loader2Icon } from '../../icons';
|
||||
|
||||
export default function PublishSettings() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { activeSite } = useSiteStore(); // Use store instead of URL params
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
|
||||
// Load publishing settings for active site
|
||||
const loadSettings = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchAPI(`/v1/integration/sites/${activeSite.id}/publishing-settings/`);
|
||||
setSettings(response.data || response);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load publishing settings:', error);
|
||||
// Set defaults if endpoint fails
|
||||
setSettings({
|
||||
auto_approval_enabled: true,
|
||||
auto_publish_enabled: true,
|
||||
daily_publish_limit: 3,
|
||||
weekly_publish_limit: 15,
|
||||
monthly_publish_limit: 50,
|
||||
publish_days: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||
publish_time_slots: ['09:00', '14:00', '18:00'],
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save publishing settings
|
||||
const saveSettings = async (newSettings: any) => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetchAPI(`/v1/integration/sites/${activeSite.id}/publishing-settings/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(newSettings),
|
||||
});
|
||||
setSettings(response.data || response);
|
||||
toast.success('Publishing settings saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save publishing settings:', error);
|
||||
toast.error('Failed to save publishing settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load settings on mount and when active site changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [activeSite]);
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site to configure publishing settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin mx-auto mb-3 text-brand-600" />
|
||||
<p>Loading publishing settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Publishing Settings" description="Configure publishing automation and scheduling" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Publishing Settings"
|
||||
breadcrumb="Publisher / Settings"
|
||||
description="Configure automatic approval, publishing limits, and scheduling"
|
||||
/>
|
||||
|
||||
{/* 3 Cards in a Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Automation */}
|
||||
<Card className="p-6 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>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Automation</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Configure automatic content approval and publishing to WordPress
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Auto-Approval</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={settings.auto_approval_enabled}
|
||||
onChange={(checked) => {
|
||||
const newSettings = { ...settings, auto_approval_enabled: checked };
|
||||
setSettings(newSettings);
|
||||
saveSettings({ auto_approval_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Automatically approve content after review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Auto-Publish</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={settings.auto_publish_enabled}
|
||||
onChange={(checked) => {
|
||||
const newSettings = { ...settings, auto_publish_enabled: checked };
|
||||
setSettings(newSettings);
|
||||
saveSettings({ auto_publish_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Publish approved content to WordPress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Publishing Limits */}
|
||||
<Card className="p-6 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">
|
||||
<LayersIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Limits</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Set maximum articles to publish per day, week, and month
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Daily</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={settings.daily_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(50, parseInt(e.target.value) || 1));
|
||||
setSettings({ ...settings, daily_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per day</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Weekly</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
value={settings.weekly_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(200, parseInt(e.target.value) || 1));
|
||||
setSettings({ ...settings, weekly_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per week</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Monthly</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={settings.monthly_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(500, parseInt(e.target.value) || 1));
|
||||
setSettings({ ...settings, monthly_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Schedule (Days + Time Slots) */}
|
||||
<Card className="p-6 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">
|
||||
<CalendarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Schedule</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Select which days and times to automatically publish content
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-2">Publishing Days</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 'mon', label: 'M' },
|
||||
{ value: 'tue', label: 'T' },
|
||||
{ value: 'wed', label: 'W' },
|
||||
{ value: 'thu', label: 'T' },
|
||||
{ value: 'fri', label: 'F' },
|
||||
{ value: 'sat', label: 'S' },
|
||||
{ value: 'sun', label: 'S' },
|
||||
].map((day) => (
|
||||
<Button
|
||||
key={day.value}
|
||||
variant={(settings.publish_days || []).includes(day.value) ? 'primary' : 'outline'}
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentDays = settings.publish_days || [];
|
||||
const newDays = currentDays.includes(day.value)
|
||||
? currentDays.filter((d: string) => d !== day.value)
|
||||
: [...currentDays, day.value];
|
||||
setSettings({ ...settings, publish_days: newDays });
|
||||
}}
|
||||
className="w-10 h-10 p-0"
|
||||
>
|
||||
{day.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Time Slots</Label>
|
||||
{(settings.publish_time_slots || []).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setSettings({ ...settings, publish_time_slots: [] });
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">In your local timezone. Content will be published at these times on selected days.</p>
|
||||
<div className="space-y-2">
|
||||
{(settings.publish_time_slots || ['09:00', '14:00', '18:00']).length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm border border-dashed border-gray-300 dark:border-gray-700 rounded-md">
|
||||
No time slots configured. Add at least one time slot.
|
||||
</div>
|
||||
) : (
|
||||
(settings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-8">#{index + 1}</span>
|
||||
<InputField
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
const newSlots = [...(settings.publish_time_slots || [])];
|
||||
newSlots[index] = e.target.value;
|
||||
setSettings({ ...settings, publish_time_slots: newSlots });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<CloseIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
title="Remove this time slot"
|
||||
onClick={() => {
|
||||
const newSlots = (settings.publish_time_slots || []).filter((_: string, i: number) => i !== index);
|
||||
setSettings({ ...settings, publish_time_slots: newSlots });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const lastSlot = (settings.publish_time_slots || [])[
|
||||
(settings.publish_time_slots || []).length - 1
|
||||
];
|
||||
// Default new slot to 12:00 or 2 hours after last slot
|
||||
let newTime = '12:00';
|
||||
if (lastSlot) {
|
||||
const [hours, mins] = lastSlot.split(':').map(Number);
|
||||
const newHours = (hours + 2) % 24;
|
||||
newTime = `${String(newHours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
const newSlots = [...(settings.publish_time_slots || []), newTime];
|
||||
setSettings({ ...settings, publish_time_slots: newSlots });
|
||||
}}
|
||||
>
|
||||
Add Time Slot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<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-1">How Publishing Works</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-brand-700 dark:text-brand-300">
|
||||
<li>Content moves from Draft → Review → Approved → Published</li>
|
||||
<li>Auto-approval moves content from Review to Approved automatically</li>
|
||||
<li>Auto-publish sends Approved content to your WordPress site</li>
|
||||
<li>You can always manually publish content using the "Publish to Site" button</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => saveSettings(settings)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Publishing Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -52,17 +52,12 @@ export default function SiteSettings() {
|
||||
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' | 'publishing') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations' | 'publishing'>(initialTab);
|
||||
const initialTab = (searchParams.get('tab') as 'general' | 'ai-settings' | 'integrations') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations'>(initialTab);
|
||||
|
||||
// Advanced Settings toggle
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
// Publishing settings state
|
||||
const [publishingSettings, setPublishingSettings] = useState<any>(null);
|
||||
const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false);
|
||||
const [publishingSettingsSaving, setPublishingSettingsSaving] = useState(false);
|
||||
|
||||
// AI Settings state (merged content generation + image settings per plan)
|
||||
// Content writing settings
|
||||
const [contentGenerationSettings, setContentGenerationSettings] = useState({
|
||||
@@ -142,7 +137,7 @@ export default function SiteSettings() {
|
||||
useEffect(() => {
|
||||
// Update tab if URL parameter changes
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab && ['general', 'ai-settings', 'integrations', 'publishing'].includes(tab)) {
|
||||
if (tab && ['general', 'ai-settings', 'integrations'].includes(tab)) {
|
||||
setActiveTab(tab as typeof activeTab);
|
||||
}
|
||||
// Handle legacy tab names - redirect content-types to integrations
|
||||
@@ -250,47 +245,6 @@ export default function SiteSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPublishingSettings = async () => {
|
||||
if (!siteId) return;
|
||||
try {
|
||||
setPublishingSettingsLoading(true);
|
||||
const response = await fetchAPI(`/v1/integration/sites/${siteId}/publishing-settings/`);
|
||||
setPublishingSettings(response.data || response);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load publishing settings:', error);
|
||||
// Set defaults if endpoint fails
|
||||
setPublishingSettings({
|
||||
auto_approval_enabled: true,
|
||||
auto_publish_enabled: true,
|
||||
daily_publish_limit: 3,
|
||||
weekly_publish_limit: 15,
|
||||
monthly_publish_limit: 50,
|
||||
publish_days: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||
publish_time_slots: ['09:00', '14:00', '18:00'],
|
||||
});
|
||||
} finally {
|
||||
setPublishingSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePublishingSettings = async (newSettings: any) => {
|
||||
if (!siteId) return;
|
||||
try {
|
||||
setPublishingSettingsSaving(true);
|
||||
const response = await fetchAPI(`/v1/integration/sites/${siteId}/publishing-settings/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(newSettings),
|
||||
});
|
||||
setPublishingSettings(response.data || response);
|
||||
toast.success('Publishing settings saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save publishing settings:', error);
|
||||
toast.error('Failed to save publishing settings');
|
||||
} finally {
|
||||
setPublishingSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Content Generation Settings
|
||||
const loadContentGenerationSettings = async () => {
|
||||
try {
|
||||
@@ -653,21 +607,6 @@ export default function SiteSettings() {
|
||||
>
|
||||
Integrations
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('publishing');
|
||||
navigate(`/sites/${siteId}/settings?tab=publishing`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'publishing'
|
||||
? 'border-info-500 text-info-600 dark:text-info-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
startIcon={<PaperPlaneIcon className={`w-4 h-4 ${activeTab === 'publishing' ? 'text-info-500' : ''}`} />}
|
||||
>
|
||||
Publishing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -922,306 +861,6 @@ export default function SiteSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publishing Tab */}
|
||||
{activeTab === 'publishing' && (
|
||||
<div className="space-y-6">
|
||||
{publishingSettingsLoading ? (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin mx-auto mb-3 text-brand-600" />
|
||||
<p>Loading publishing settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : publishingSettings ? (
|
||||
<>
|
||||
{/* 3 Cards in a Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Automation */}
|
||||
<Card className="p-6 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>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Automation</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Configure automatic content approval and publishing to WordPress
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Auto-Approval</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={publishingSettings.auto_approval_enabled}
|
||||
onChange={(checked) => {
|
||||
const newSettings = { ...publishingSettings, auto_approval_enabled: checked };
|
||||
setPublishingSettings(newSettings);
|
||||
savePublishingSettings({ auto_approval_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Automatically approve content after review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Auto-Publish</Label>
|
||||
<Switch
|
||||
label=""
|
||||
checked={publishingSettings.auto_publish_enabled}
|
||||
onChange={(checked) => {
|
||||
const newSettings = { ...publishingSettings, auto_publish_enabled: checked };
|
||||
setPublishingSettings(newSettings);
|
||||
savePublishingSettings({ auto_publish_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Publish approved content to WordPress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Publishing Limits */}
|
||||
<Card className="p-6 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">
|
||||
<LayersIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Limits</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Set maximum articles to publish per day, week, and month
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Daily</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={publishingSettings.daily_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(50, parseInt(e.target.value) || 1));
|
||||
setPublishingSettings({ ...publishingSettings, daily_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per day</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Weekly</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
value={publishingSettings.weekly_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(200, parseInt(e.target.value) || 1));
|
||||
setPublishingSettings({ ...publishingSettings, weekly_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per week</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Monthly</Label>
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={publishingSettings.monthly_publish_limit}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(500, parseInt(e.target.value) || 1));
|
||||
setPublishingSettings({ ...publishingSettings, monthly_publish_limit: value });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Articles per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Schedule (Days + Time Slots) */}
|
||||
<Card className="p-6 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">
|
||||
<CalendarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Schedule</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Select which days and times to automatically publish content
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-2">Publishing Days</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 'mon', label: 'M' },
|
||||
{ value: 'tue', label: 'T' },
|
||||
{ value: 'wed', label: 'W' },
|
||||
{ value: 'thu', label: 'T' },
|
||||
{ value: 'fri', label: 'F' },
|
||||
{ value: 'sat', label: 'S' },
|
||||
{ value: 'sun', label: 'S' },
|
||||
].map((day) => (
|
||||
<Button
|
||||
key={day.value}
|
||||
variant={(publishingSettings.publish_days || []).includes(day.value) ? 'primary' : 'outline'}
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentDays = publishingSettings.publish_days || [];
|
||||
const newDays = currentDays.includes(day.value)
|
||||
? currentDays.filter((d: string) => d !== day.value)
|
||||
: [...currentDays, day.value];
|
||||
setPublishingSettings({ ...publishingSettings, publish_days: newDays });
|
||||
// Don't auto-save - let user click Save button
|
||||
}}
|
||||
className="w-10 h-10 p-0"
|
||||
>
|
||||
{day.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Time Slots</Label>
|
||||
{(publishingSettings.publish_time_slots || []).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setPublishingSettings({ ...publishingSettings, publish_time_slots: [] });
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">In your local timezone. Content will be published at these times on selected days.</p>
|
||||
<div className="space-y-2">
|
||||
{(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm border border-dashed border-gray-300 dark:border-gray-700 rounded-md">
|
||||
No time slots configured. Add at least one time slot.
|
||||
</div>
|
||||
) : (
|
||||
(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-8">#{index + 1}</span>
|
||||
<InputField
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
const newSlots = [...(publishingSettings.publish_time_slots || [])];
|
||||
newSlots[index] = e.target.value;
|
||||
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<CloseIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
title="Remove this time slot"
|
||||
onClick={() => {
|
||||
const newSlots = (publishingSettings.publish_time_slots || []).filter((_: string, i: number) => i !== index);
|
||||
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
|
||||
// Don't auto-save - let user click Save button
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const lastSlot = (publishingSettings.publish_time_slots || [])[
|
||||
(publishingSettings.publish_time_slots || []).length - 1
|
||||
];
|
||||
// Default new slot to 12:00 or 2 hours after last slot
|
||||
let newTime = '12:00';
|
||||
if (lastSlot) {
|
||||
const [hours, mins] = lastSlot.split(':').map(Number);
|
||||
const newHours = (hours + 2) % 24;
|
||||
newTime = `${String(newHours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
const newSlots = [...(publishingSettings.publish_time_slots || []), newTime];
|
||||
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
|
||||
// Don't auto-save - let user click Save button
|
||||
}}
|
||||
>
|
||||
Add Time Slot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<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-1">How Publishing Works</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-brand-700 dark:text-brand-300">
|
||||
<li>Content moves from Draft → Review → Approved → Published</li>
|
||||
<li>Auto-approval moves content from Review to Approved automatically</li>
|
||||
<li>Auto-publish sends Approved content to your WordPress site</li>
|
||||
<li>You can always manually publish content using the "Publish to Site" button</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => savePublishingSettings(publishingSettings)}
|
||||
isLoading={publishingSettingsSaving}
|
||||
disabled={publishingSettingsSaving}
|
||||
>
|
||||
Save Publishing Settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Failed to load publishing settings. Please try again.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={loadPublishingSettings}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="space-y-6">
|
||||
{/* General Tab */}
|
||||
|
||||
@@ -720,9 +720,9 @@ export default function Approved() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Content Approved"
|
||||
badge={{ icon: <CheckCircleIcon />, color: 'green' }}
|
||||
parent="Writer"
|
||||
title="Publish / Schedule"
|
||||
badge={{ icon: <RocketLaunchIcon />, color: 'green' }}
|
||||
parent="Publisher"
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
|
||||
@@ -391,7 +391,7 @@ export default function Review() {
|
||||
<PageHeader
|
||||
title="Content Review"
|
||||
badge={{ icon: <ClipboardDocumentCheckIcon />, color: 'emerald' }}
|
||||
parent="Writer"
|
||||
parent="Publisher"
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Account Settings Page - Consolidated Settings
|
||||
* Tabs: Account, Profile, Team
|
||||
* Tab selection driven by URL path for sidebar navigation
|
||||
* Account Settings Page - Single Scrollable Page
|
||||
* All settings displayed vertically: Account, Profile, Team
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
SaveIcon, Loader2Icon, SettingsIcon, UserIcon, UsersIcon, UserPlusIcon, LockIcon, LockIcon as ShieldIcon, XIcon
|
||||
SaveIcon, Loader2Icon, SettingsIcon, UserIcon, UsersIcon, UserPlusIcon, LockIcon, LockIcon as ShieldIcon
|
||||
} from '../../icons';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
@@ -34,23 +32,12 @@ import {
|
||||
type TeamMember,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
type TabType = 'account' | 'profile' | 'team';
|
||||
|
||||
// Map URL paths to tab types
|
||||
function getTabFromPath(pathname: string): TabType {
|
||||
if (pathname.includes('/profile')) return 'profile';
|
||||
if (pathname.includes('/team')) return 'team';
|
||||
return 'account';
|
||||
}
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const toast = useToast();
|
||||
const location = useLocation();
|
||||
const { user, refreshUser } = useAuthStore();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
// Derive active tab from URL path
|
||||
const activeTab = getTabFromPath(location.pathname);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAccount, setSavingAccount] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
@@ -100,10 +87,6 @@ export default function AccountSettingsPage() {
|
||||
last_name: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Load profile from auth store user data
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -121,6 +104,11 @@ export default function AccountSettingsPage() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadTeamMembers();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
startLoading('Loading settings...');
|
||||
@@ -156,17 +144,10 @@ export default function AccountSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Load team members when team tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab === 'team' && members.length === 0) {
|
||||
loadTeamMembers();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const handleAccountSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSaving(true);
|
||||
setSavingAccount(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
await updateAccountSettings(accountForm);
|
||||
@@ -177,14 +158,14 @@ export default function AccountSettingsPage() {
|
||||
setError(err.message || 'Failed to update account settings');
|
||||
toast.error(err.message || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSaving(true);
|
||||
setSavingProfile(true);
|
||||
// Profile data is stored in auth user - refresh after save
|
||||
// Note: Full profile API would go here when backend supports it
|
||||
toast.success('Profile settings saved');
|
||||
@@ -192,7 +173,7 @@ export default function AccountSettingsPage() {
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to save profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,68 +245,42 @@ export default function AccountSettingsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'account' as TabType, label: 'Account', icon: <SettingsIcon className="w-4 h-4" /> },
|
||||
{ id: 'profile' as TabType, label: 'Profile', icon: <UserIcon className="w-4 h-4" /> },
|
||||
{ id: 'team' as TabType, label: 'Team', icon: <UsersIcon className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
// Page titles based on active tab
|
||||
const pageTitles = {
|
||||
account: { title: 'Account Information', description: 'Manage your organization and billing information' },
|
||||
profile: { title: 'Profile Settings', description: 'Update your personal information and preferences' },
|
||||
team: { title: 'Team Management', description: 'Invite and manage team members' },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
||||
<PageHeader
|
||||
title={pageTitles[activeTab].title}
|
||||
description={pageTitles[activeTab].description}
|
||||
title="Account Settings"
|
||||
description="Manage your account information, profile, and team"
|
||||
badge={{ icon: <SettingsIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
parent="Account Settings"
|
||||
/>
|
||||
{/* Tab Content */}
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{error && (
|
||||
<div className="p-4 bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg">
|
||||
<p className="text-error-800 dark:text-error-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 rounded-lg">
|
||||
<p className="text-success-800 dark:text-success-200">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAccountSubmit} className="space-y-6">
|
||||
{/* Account Information */}
|
||||
<div className="space-y-8">
|
||||
{/* Account Settings Section */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
Account Information
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleAccountSubmit}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Account Information Card */}
|
||||
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Account Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="name"
|
||||
label="Account Name"
|
||||
value={accountForm.name}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
label="Account Slug"
|
||||
value={settings?.slug || ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Account Details</h3>
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
type="text"
|
||||
name="name"
|
||||
label="Account Name"
|
||||
value={accountForm.name}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
<InputField
|
||||
type="text"
|
||||
label="Account Slug"
|
||||
value={settings?.slug || ''}
|
||||
disabled
|
||||
/>
|
||||
<InputField
|
||||
type="email"
|
||||
name="billing_email"
|
||||
@@ -336,74 +291,64 @@ export default function AccountSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Billing Address */}
|
||||
{/* Billing Address Card */}
|
||||
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Billing Address</h2>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Billing Address</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_address_line1"
|
||||
label="Address Line 1"
|
||||
value={accountForm.billing_address_line1}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_address_line2"
|
||||
label="Address Line 2"
|
||||
value={accountForm.billing_address_line2}
|
||||
onChange={handleAccountChange}
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_address_line1"
|
||||
label="Address Line 1"
|
||||
value={accountForm.billing_address_line1}
|
||||
name="billing_city"
|
||||
label="City"
|
||||
value={accountForm.billing_city}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_state"
|
||||
label="State"
|
||||
value={accountForm.billing_state}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_address_line2"
|
||||
label="Address Line 2"
|
||||
value={accountForm.billing_address_line2}
|
||||
name="billing_postal_code"
|
||||
label="Postal Code"
|
||||
value={accountForm.billing_postal_code}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_city"
|
||||
label="City"
|
||||
value={accountForm.billing_city}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_state"
|
||||
label="State/Province"
|
||||
value={accountForm.billing_state}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_postal_code"
|
||||
label="Postal Code"
|
||||
value={accountForm.billing_postal_code}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
name="billing_country"
|
||||
label="Country"
|
||||
value={accountForm.billing_country}
|
||||
onChange={handleAccountChange}
|
||||
placeholder="US, GB, IN, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tax Information */}
|
||||
{/* Tax Information Card */}
|
||||
<Card className="p-6 border-l-4 border-l-success-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Tax Information</h2>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Tax Information</h3>
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
type="text"
|
||||
name="tax_id"
|
||||
@@ -412,70 +357,73 @@ export default function AccountSettingsPage() {
|
||||
onChange={handleAccountChange}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-4">
|
||||
<p className="font-medium mb-2">Tax Information</p>
|
||||
<p>Provide your tax ID or VAT number if applicable for your region.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{/* Save Button for Account Section */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={savingAccount}
|
||||
startIcon={savingAccount ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{savingAccount ? 'Saving...' : 'Save Account Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
||||
{/* Profile Settings Section */}
|
||||
<div className="space-y-4 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
Profile Settings
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleProfileSubmit}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* About You Card */}
|
||||
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">About You</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
label="First Name"
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
label="Last Name"
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="email"
|
||||
label="Email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="tel"
|
||||
label="Phone Number (optional)"
|
||||
value={profileForm.phone}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">About You</h3>
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
type="text"
|
||||
label="First Name"
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, firstName: e.target.value })}
|
||||
/>
|
||||
<InputField
|
||||
type="text"
|
||||
label="Last Name"
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, lastName: e.target.value })}
|
||||
/>
|
||||
<InputField
|
||||
type="email"
|
||||
label="Email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, email: e.target.value })}
|
||||
/>
|
||||
<InputField
|
||||
type="tel"
|
||||
label="Phone Number (optional)"
|
||||
value={profileForm.phone}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Preferences Card */}
|
||||
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Timezone
|
||||
@@ -511,88 +459,98 @@ export default function AccountSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card className="p-6 border-l-4 border-l-success-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Notifications</h2>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Notifications</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Choose what emails you want to receive:
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">Important Updates</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Get notified about important changes to your account
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={profileForm.emailNotifications}
|
||||
onChange={(checked) => setProfileForm({ ...profileForm, emailNotifications: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">Tips & Product Updates</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Hear about new features and content tips
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">Important Updates</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Get notified about important changes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={profileForm.marketingEmails}
|
||||
onChange={(checked) => setProfileForm({ ...profileForm, marketingEmails: checked })}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">Tips & Updates</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Hear about new features and tips
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Security Card */}
|
||||
<Card className="p-6 border-l-4 border-l-warning-500">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<LockIcon className="w-5 h-5" />
|
||||
Security
|
||||
</h2>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Keep your account secure by updating your password regularly.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
type="button"
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{/* Save Button for Profile Section */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={savingProfile}
|
||||
startIcon={savingProfile ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{savingProfile ? 'Saving...' : 'Save Profile Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Team Tab */}
|
||||
{activeTab === 'team' && (
|
||||
<div className="space-y-6">
|
||||
{/* Team Members Section */}
|
||||
<ComponentCard
|
||||
title="Team Members"
|
||||
desc="Manage who can access your account"
|
||||
headerContent={
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
startIcon={<UserPlusIcon className="w-4 h-4" />}
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
>
|
||||
Invite Someone
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Team Management Section */}
|
||||
<div className="space-y-4 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5" />
|
||||
Team Management
|
||||
</h2>
|
||||
|
||||
{/* Team Members Section */}
|
||||
<ComponentCard
|
||||
title="Team Members"
|
||||
desc="Manage who can access your account"
|
||||
headerContent={
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
startIcon={<UserPlusIcon className="w-4 h-4" />}
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
>
|
||||
Invite Someone
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{teamLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2Icon className="w-6 h-6 animate-spin text-[var(--color-brand-500)]" />
|
||||
@@ -696,10 +654,10 @@ export default function AccountSettingsPage() {
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
<Modal
|
||||
{/* Invite Modal */}
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => {
|
||||
setShowInviteModal(false);
|
||||
|
||||
1051
frontend/src/pages/account/AccountSettingsPage.tsx.backup
Normal file
1051
frontend/src/pages/account/AccountSettingsPage.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -460,7 +460,9 @@
|
||||
----------------------------------------------------------------- */
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-1.5 font-medium rounded-lg text-theme-sm;
|
||||
@apply relative flex items-center w-full gap-3 px-3 font-medium rounded-lg text-theme-sm;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
|
||||
Reference in New Issue
Block a user