SEction 2 part 2
This commit is contained in:
@@ -107,6 +107,9 @@ const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
|
||||
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
||||
const PublishingQueue = lazy(() => import("./pages/Sites/PublishingQueue"));
|
||||
|
||||
// Publisher Module - Lazy loaded
|
||||
const ContentCalendar = lazy(() => import("./pages/Publisher/ContentCalendar"));
|
||||
|
||||
// Setup - Lazy loaded
|
||||
const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard"));
|
||||
|
||||
@@ -183,6 +186,10 @@ export default function App() {
|
||||
{/* Automation Module */}
|
||||
<Route path="/automation" element={<AutomationPage />} />
|
||||
|
||||
{/* Publisher Module - Content Calendar */}
|
||||
<Route path="/publisher" element={<Navigate to="/publisher/content-calendar" replace />} />
|
||||
<Route path="/publisher/content-calendar" element={<ContentCalendar />} />
|
||||
|
||||
{/* Linker Module - Redirect dashboard to content */}
|
||||
<Route path="/linker" element={<Navigate to="/linker/content" replace />} />
|
||||
<Route path="/linker/content" element={<LinkerContentList />} />
|
||||
@@ -272,7 +279,8 @@ export default function App() {
|
||||
<Route path="/sites/:id/settings" element={<SiteSettings />} />
|
||||
<Route path="/sites/:id/sync" element={<SyncDashboard />} />
|
||||
<Route path="/sites/:id/deploy" element={<DeploymentPanel />} />
|
||||
<Route path="/sites/:id/publishing-queue" element={<PublishingQueue />} />
|
||||
{/* Legacy redirect - Publishing Queue moved to Content Calendar */}
|
||||
<Route path="/sites/:id/publishing-queue" element={<Navigate to="/publisher/content-calendar" replace />} />
|
||||
<Route path="/sites/:id/posts/:postId" element={<PostEditor />} />
|
||||
<Route path="/sites/:id/posts/:postId/edit" element={<PostEditor />} />
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ButtonGroupItem: React.FC<ButtonGroupItemProps> = ({
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
? "bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"
|
||||
: ""
|
||||
} ${className}`}
|
||||
type="button"
|
||||
|
||||
81
frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx
Normal file
81
frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* CalendarItemTooltip - Tooltip for calendar items with rich content
|
||||
* Used in Content Calendar page to show content details on hover
|
||||
*/
|
||||
import React, { ReactNode } from 'react';
|
||||
import { EnhancedTooltip } from './EnhancedTooltip';
|
||||
|
||||
interface CalendarItemTooltipProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
status: 'scheduled' | 'published' | 'publishing' | 'failed';
|
||||
contentType?: string;
|
||||
date?: string | null;
|
||||
dateLabel?: string;
|
||||
wordCount?: number;
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: '⏰',
|
||||
label: 'Scheduled',
|
||||
color: 'text-warning-300',
|
||||
},
|
||||
published: {
|
||||
icon: '✓',
|
||||
label: 'Published',
|
||||
color: 'text-success-300',
|
||||
},
|
||||
publishing: {
|
||||
icon: '⚡',
|
||||
label: 'Publishing',
|
||||
color: 'text-brand-300',
|
||||
},
|
||||
failed: {
|
||||
icon: '✗',
|
||||
label: 'Failed',
|
||||
color: 'text-error-300',
|
||||
},
|
||||
};
|
||||
|
||||
export const CalendarItemTooltip: React.FC<CalendarItemTooltipProps> = ({
|
||||
children,
|
||||
title,
|
||||
status,
|
||||
contentType,
|
||||
date,
|
||||
dateLabel = 'Date',
|
||||
wordCount,
|
||||
placement = 'top',
|
||||
}) => {
|
||||
const config = statusConfig[status];
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="min-w-[200px] max-w-[280px]">
|
||||
<div className={`font-semibold mb-1 ${config.color}`}>
|
||||
{config.icon} {config.label}
|
||||
</div>
|
||||
<div className="font-medium mb-2 whitespace-normal text-white">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-gray-400 text-[10px] space-y-0.5">
|
||||
{contentType && <div>Type: {contentType}</div>}
|
||||
{date && (
|
||||
<div>
|
||||
{dateLabel}: {new Date(date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{wordCount !== undefined && <div>Words: {wordCount}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedTooltip content={tooltipContent} placement={placement} delay={100}>
|
||||
{children}
|
||||
</EnhancedTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarItemTooltip;
|
||||
@@ -1,2 +1,3 @@
|
||||
export { Tooltip } from "./Tooltip";
|
||||
|
||||
export { EnhancedTooltip } from "./EnhancedTooltip";
|
||||
export { CalendarItemTooltip } from "./CalendarItemTooltip";
|
||||
|
||||
@@ -22,6 +22,7 @@ const SITE_AND_SECTOR_ROUTES = [
|
||||
|
||||
const SINGLE_SITE_ROUTES = [
|
||||
'/automation',
|
||||
'/publisher', // Content Calendar page
|
||||
'/account/content-settings', // Content settings and sub-pages
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
UserIcon,
|
||||
UserCircleIcon,
|
||||
ShootingStarIcon,
|
||||
CalendarIcon,
|
||||
} from "../icons";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
@@ -68,7 +69,7 @@ const AppSidebar: React.FC = () => {
|
||||
// New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS
|
||||
// Module visibility is controlled by GlobalModuleSettings (Django Admin only)
|
||||
const menuSections: MenuSection[] = useMemo(() => {
|
||||
// SETUP section items - Ordered: Setup Wizard → Sites → Add Keywords → Content Settings → Thinker
|
||||
// SETUP section items - Ordered: Setup Wizard → Sites → Add Keywords → Thinker
|
||||
const setupItems: NavItem[] = [];
|
||||
|
||||
// Setup Wizard at top - guides users through site setup
|
||||
@@ -92,16 +93,7 @@ const AppSidebar: React.FC = () => {
|
||||
path: "/setup/add-keywords",
|
||||
});
|
||||
|
||||
// Content Settings third - with dropdown
|
||||
setupItems.push({
|
||||
icon: <PageIcon />,
|
||||
name: "Content Settings",
|
||||
subItems: [
|
||||
{ name: "Content Generation", path: "/account/content-settings" },
|
||||
{ name: "Publishing", path: "/account/content-settings/publishing" },
|
||||
{ name: "Image Settings", path: "/account/content-settings/images" },
|
||||
],
|
||||
});
|
||||
// Content Settings moved to Site Settings tabs - removed from sidebar
|
||||
|
||||
// Add Thinker last (admin only - prompts and AI settings)
|
||||
if (isModuleEnabled('thinker')) {
|
||||
@@ -156,6 +148,13 @@ const AppSidebar: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 [
|
||||
@@ -326,14 +325,18 @@ const AppSidebar: React.FC = () => {
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((nav, itemIndex) => (
|
||||
<li key={nav.name}>
|
||||
.map((nav, itemIndex) => {
|
||||
// Check if any subitem is active to determine parent active state
|
||||
const hasActiveSubItem = nav.subItems?.some(subItem => isActive(subItem.path)) ?? false;
|
||||
const isSubmenuOpen = openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex;
|
||||
|
||||
return (
|
||||
<li key={`${sectionIndex}-${nav.name}-${location.pathname}`}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(sectionIndex, itemIndex)}
|
||||
className={`menu-item group ${
|
||||
openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex ||
|
||||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
|
||||
hasActiveSubItem
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${
|
||||
@@ -343,9 +346,8 @@ const AppSidebar: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${
|
||||
(openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex) ||
|
||||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
|
||||
className={`menu-item-icon-size ${
|
||||
hasActiveSubItem
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
@@ -358,8 +360,7 @@ const AppSidebar: React.FC = () => {
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<ChevronDownIcon
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
|
||||
openSubmenu?.sectionIndex === sectionIndex &&
|
||||
openSubmenu?.itemIndex === itemIndex
|
||||
isSubmenuOpen
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
@@ -396,8 +397,7 @@ const AppSidebar: React.FC = () => {
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex
|
||||
height: isSubmenuOpen
|
||||
? `${subMenuHeight[`${sectionIndex}-${itemIndex}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
@@ -445,7 +445,8 @@ const AppSidebar: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -497,7 +498,7 @@ const AppSidebar: React.FC = () => {
|
||||
<nav>
|
||||
<div className="flex flex-col gap-1">
|
||||
{allSections.map((section, sectionIndex) => (
|
||||
<div key={section.label || `section-${sectionIndex}`} className={section.label ? "mt-4" : ""}>
|
||||
<div key={section.label || `section-${sectionIndex}`} className={section.label ? "mt-2" : ""}>
|
||||
{section.label && (
|
||||
<h2
|
||||
className={`mb-2 text-xs font-medium uppercase flex leading-[20px] text-gray-500 dark:text-gray-400 ${
|
||||
|
||||
683
frontend/src/pages/Publisher/ContentCalendar.tsx
Normal file
683
frontend/src/pages/Publisher/ContentCalendar.tsx
Normal file
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* Content Calendar Page
|
||||
* Shows scheduled content for publishing to external site
|
||||
* Allows reordering, pausing, and viewing calendar with drag-drop support
|
||||
* Site selector in app header (inherited from layout)
|
||||
*
|
||||
* Content Statuses:
|
||||
* - status: 'draft' | 'review' | 'approved' | 'published' (content workflow status)
|
||||
* - site_status: 'not_published' | 'scheduled' | 'publishing' | 'published' | 'failed' (publishing status)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import IconButton from '../../components/ui/button/IconButton';
|
||||
import { ButtonGroup, ButtonGroupItem } from '../../components/ui/button-group/ButtonGroup';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { CalendarItemTooltip } from '../../components/ui/tooltip';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { fetchContent, Content, fetchAPI } from '../../services/api';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ListIcon,
|
||||
TrashBinIcon,
|
||||
EyeIcon,
|
||||
PencilIcon,
|
||||
FileTextIcon,
|
||||
} from '../../icons';
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
|
||||
// API function to schedule content
|
||||
async function scheduleContent(contentId: number, scheduledDate: string): Promise<Content> {
|
||||
return fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
|
||||
});
|
||||
}
|
||||
|
||||
// API function to unschedule content
|
||||
async function unscheduleContent(contentId: number): Promise<Content> {
|
||||
return fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContentCalendar() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { activeSite } = useSiteStore();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allContent, setAllContent] = useState<Content[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
|
||||
const [draggedItem, setDraggedItem] = useState<Content | null>(null);
|
||||
|
||||
// Derived state: Queue items (scheduled or publishing - future dates only for queue)
|
||||
const queueItems = useMemo(() => {
|
||||
return allContent
|
||||
.filter((c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing')
|
||||
.sort((a: Content, b: Content) => {
|
||||
const dateA = a.scheduled_publish_at ? new Date(a.scheduled_publish_at).getTime() : 0;
|
||||
const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
});
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Published items (site_status = 'published')
|
||||
const publishedItems = useMemo(() => {
|
||||
return allContent.filter((c: Content) => c.site_status === 'published');
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Approved items for sidebar (approved but not scheduled/publishing/published)
|
||||
const approvedItems = useMemo(() => {
|
||||
return allContent.filter(
|
||||
(c: Content) =>
|
||||
c.status === 'approved' &&
|
||||
c.site_status !== 'scheduled' &&
|
||||
c.site_status !== 'publishing' &&
|
||||
c.site_status !== 'published'
|
||||
);
|
||||
}, [allContent]);
|
||||
|
||||
// Calculate stats from allContent
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Published in last 30 days
|
||||
const publishedLast30Days = allContent.filter((c: Content) => {
|
||||
if (c.site_status !== 'published') return false;
|
||||
const publishDate = c.site_status_updated_at ? new Date(c.site_status_updated_at) : null;
|
||||
return publishDate && publishDate >= thirtyDaysAgo;
|
||||
}).length;
|
||||
|
||||
// Scheduled in next 30 days
|
||||
const scheduledNext30Days = allContent.filter((c: Content) => {
|
||||
if (c.site_status !== 'scheduled') return false;
|
||||
const schedDate = c.scheduled_publish_at ? new Date(c.scheduled_publish_at) : null;
|
||||
return schedDate && schedDate >= now && schedDate <= thirtyDaysFromNow;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
scheduled: allContent.filter((c: Content) => c.site_status === 'scheduled').length,
|
||||
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
|
||||
published: allContent.filter((c: Content) => c.site_status === 'published').length,
|
||||
review: allContent.filter((c: Content) => c.status === 'review').length,
|
||||
approved: allContent.filter((c: Content) => c.status === 'approved' && c.site_status !== 'published').length,
|
||||
publishedLast30Days,
|
||||
scheduledNext30Days,
|
||||
};
|
||||
}, [allContent]);
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
if (!activeSite?.id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all content for this site
|
||||
const response = await fetchContent({
|
||||
site_id: activeSite.id,
|
||||
page_size: 200,
|
||||
});
|
||||
|
||||
setAllContent(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSite?.id, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSite?.id) {
|
||||
loadQueue();
|
||||
}
|
||||
}, [activeSite?.id, loadQueue]);
|
||||
|
||||
// Drag and drop handlers for list view
|
||||
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
|
||||
setDraggedItem(item);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('source', source);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDropOnList = async (e: React.DragEvent, targetItem: Content) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem || draggedItem.id === targetItem.id) return;
|
||||
|
||||
// If from approved, schedule it
|
||||
const fromApproved = approvedItems.some(i => i.id === draggedItem.id);
|
||||
|
||||
if (fromApproved) {
|
||||
try {
|
||||
// Schedule for tomorrow by default when dropping on list
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
|
||||
await scheduleContent(draggedItem.id, tomorrow.toISOString());
|
||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||
loadQueue(); // Reload to get updated data
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to schedule: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
setDraggedItem(null);
|
||||
};
|
||||
|
||||
// Calendar drag and drop
|
||||
const handleDropOnCalendarDate = async (e: React.DragEvent, date: Date) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem) return;
|
||||
|
||||
// Set the scheduled time to 9 AM on the target date
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(9, 0, 0, 0);
|
||||
|
||||
try {
|
||||
await scheduleContent(draggedItem.id, newDate.toISOString());
|
||||
toast.success(`Scheduled for ${newDate.toLocaleDateString()}`);
|
||||
loadQueue(); // Reload to get updated data from server
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to schedule: ${error.message}`);
|
||||
}
|
||||
|
||||
setDraggedItem(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedItem(null);
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = async (item: Content) => {
|
||||
try {
|
||||
await unscheduleContent(item.id);
|
||||
toast.success('Removed from queue');
|
||||
loadQueue(); // Reload to get updated data
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to remove: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewContent = (item: Content) => {
|
||||
navigate(`/writer/content/${item.id}`);
|
||||
};
|
||||
|
||||
const formatScheduledTime = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return 'Not scheduled';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (item: Content) => {
|
||||
if (item.site_status === 'publishing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300">
|
||||
<ArrowRightIcon className="w-3 h-3 animate-pulse" />
|
||||
Publishing...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-300">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
Scheduled
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Calendar view helpers
|
||||
const getCalendarDays = () => {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
// Start from beginning of current week
|
||||
const startOfWeek = new Date(today);
|
||||
startOfWeek.setDate(today.getDate() - today.getDay());
|
||||
|
||||
// Show 4 weeks
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const date = new Date(startOfWeek);
|
||||
date.setDate(startOfWeek.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
// Get scheduled items for a specific date
|
||||
const getScheduledItemsForDate = (date: Date) => {
|
||||
return queueItems.filter(item => {
|
||||
if (!item.scheduled_publish_at) return false;
|
||||
const itemDate = new Date(item.scheduled_publish_at);
|
||||
return (
|
||||
itemDate.getDate() === date.getDate() &&
|
||||
itemDate.getMonth() === date.getMonth() &&
|
||||
itemDate.getFullYear() === date.getFullYear()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Get published items for a specific date
|
||||
const getPublishedItemsForDate = (date: Date) => {
|
||||
return publishedItems.filter(item => {
|
||||
// Use site_status_updated_at as publish date
|
||||
const publishDate = item.site_status_updated_at || item.updated_at;
|
||||
if (!publishDate) return false;
|
||||
const itemDate = new Date(publishDate);
|
||||
return (
|
||||
itemDate.getDate() === date.getDate() &&
|
||||
itemDate.getMonth() === date.getMonth() &&
|
||||
itemDate.getFullYear() === date.getFullYear()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<CalendarIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site from the header to view the content calendar</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading calendar...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
|
||||
{/* Header - Site selector is in app header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<PageHeader
|
||||
title="Content Calendar"
|
||||
badge={{ icon: <CalendarIcon />, color: 'amber' }}
|
||||
hideSiteSector
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview - New layout with count on right, bigger labels, descriptions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{/* Review */}
|
||||
<Card
|
||||
className="p-4 cursor-pointer hover:border-purple-500 transition-colors group"
|
||||
onClick={() => navigate('/writer/review')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<PencilIcon className="w-4 h-4 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Review</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Content awaiting review before approval</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">{stats.review}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Approved */}
|
||||
<Card
|
||||
className="p-4 cursor-pointer hover:border-success-500 transition-colors group"
|
||||
onClick={() => navigate('/writer/approved')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-4 h-4 text-success-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Approved</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Ready to be scheduled for publishing</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-success-600 dark:text-success-400">{stats.approved}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Published */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ArrowRightIcon className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Published</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Successfully published to your site</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-brand-600 dark:text-brand-400">{stats.published}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Scheduled */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<ClockIcon className="w-4 h-4 text-warning-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Scheduled</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Queued for automatic publishing</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-warning-600 dark:text-warning-400">{stats.scheduled}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 30-day summary */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-brand-600">{stats.publishedLast30Days}</span> published in last 30 days
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-warning-600">{stats.scheduledNext30Days}</span> scheduled for next 30 days
|
||||
</span>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'calendar'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
startIcon={<CalendarIcon className="w-4 h-4" />}
|
||||
>
|
||||
Calendar
|
||||
</ButtonGroupItem>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'list'}
|
||||
onClick={() => setViewMode('list')}
|
||||
startIcon={<ListIcon className="w-4 h-4" />}
|
||||
>
|
||||
List
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area with Sidebar */}
|
||||
<div className="flex gap-6">
|
||||
{/* Main Calendar/List View */}
|
||||
<div className="flex-1">
|
||||
{queueItems.length === 0 && approvedItems.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<CalendarIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No content to schedule
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Approve content from the review queue to schedule for publishing.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => navigate('/writer/review')}>
|
||||
Go to Review Queue
|
||||
</Button>
|
||||
</Card>
|
||||
) : viewMode === 'list' ? (
|
||||
/* List View */
|
||||
<ComponentCard title="Publishing Queue" desc="Drag items to reorder or drag from sidebar to add.">
|
||||
<div
|
||||
className="space-y-2 min-h-[200px]"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem) return;
|
||||
// If dropping on empty area, schedule for tomorrow
|
||||
const fromApproved = approvedItems.some(i => i.id === draggedItem.id);
|
||||
if (fromApproved) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
||||
.then(() => {
|
||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||
loadQueue();
|
||||
})
|
||||
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
||||
}
|
||||
setDraggedItem(null);
|
||||
}}
|
||||
>
|
||||
{queueItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<p className="text-gray-500 dark:text-gray-400">Drop approved content here to schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
queueItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, 'queue')}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDropOnList(e, item)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg border-2
|
||||
${draggedItem?.id === item.id ? 'border-brand-500 opacity-50' : 'border-gray-200 dark:border-gray-700'}
|
||||
hover:border-brand-300 dark:hover:border-brand-700 transition-all cursor-move
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="w-3.5 h-3.5" />
|
||||
{formatScheduledTime(item.scheduled_publish_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(item)}
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={<EyeIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => handleViewContent(item)}
|
||||
title="View content"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<TrashBinIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFromQueue(item)}
|
||||
title="Remove from queue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
) : (
|
||||
/* Calendar View with drag-drop */
|
||||
<ComponentCard title="Calendar View" desc="Drag content from sidebar to schedule. Published items shown with glass effect.">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{/* Day headers */}
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center text-sm font-medium text-gray-500 dark:text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{getCalendarDays().map((date, index) => {
|
||||
const scheduledItems = getScheduledItemsForDate(date);
|
||||
const publishedOnDate = getPublishedItemsForDate(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const isPast = date < new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onDragOver={!isPast ? handleDragOver : undefined}
|
||||
onDrop={!isPast ? (e) => handleDropOnCalendarDate(e, date) : undefined}
|
||||
className={`
|
||||
min-h-[100px] p-2 rounded-lg border transition-colors
|
||||
${isToday
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: isPast
|
||||
? 'border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
}
|
||||
${!isPast && draggedItem ? 'border-dashed border-brand-300 dark:border-brand-600' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${isToday ? 'text-brand-600' : isPast ? 'text-gray-400 dark:text-gray-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{date.getDate()}
|
||||
{date.getDate() === 1 && (
|
||||
<span className="ml-1 text-xs">
|
||||
{date.toLocaleString('default', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{/* Published items with glass effect */}
|
||||
{publishedOnDate.slice(0, 2).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
status="published"
|
||||
contentType={item.content_type || 'Article'}
|
||||
date={item.site_status_updated_at}
|
||||
dateLabel="Published"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
onClick={() => handleViewContent(item)}
|
||||
className="text-xs p-1.5 bg-success-100/60 dark:bg-success-900/20 text-success-700 dark:text-success-300 rounded truncate cursor-pointer backdrop-blur-sm border border-success-200/50 dark:border-success-800/50"
|
||||
>
|
||||
✓ {item.title}
|
||||
</div>
|
||||
</CalendarItemTooltip>
|
||||
))}
|
||||
{/* Scheduled items */}
|
||||
{scheduledItems.slice(0, 3 - publishedOnDate.length).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
status="scheduled"
|
||||
contentType={item.content_type || 'Article'}
|
||||
date={item.scheduled_publish_at}
|
||||
dateLabel="Scheduled"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
draggable={!isPast}
|
||||
onDragStart={!isPast ? (e) => handleDragStart(e, item, 'queue') : undefined}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleViewContent(item)}
|
||||
className={`text-xs p-1.5 rounded truncate transition-colors ${
|
||||
isPast
|
||||
? 'bg-gray-100/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 backdrop-blur-sm cursor-default'
|
||||
: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 cursor-move hover:bg-warning-200 dark:hover:bg-warning-900/50'
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
</CalendarItemTooltip>
|
||||
))}
|
||||
{(scheduledItems.length + publishedOnDate.length) > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{(scheduledItems.length + publishedOnDate.length) - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approved Content Sidebar - reduced width by 15% (80 -> 68) */}
|
||||
<div className="w-68 flex-shrink-0">
|
||||
<ComponentCard title="Approved Content" desc="Drag to publishing queue to schedule">
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{approvedItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<FileTextIcon className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No approved content</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => navigate('/writer/approved')}
|
||||
>
|
||||
View All Approved
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
approvedItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, 'approved')}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700
|
||||
${draggedItem?.id === item.id ? 'opacity-50 border-brand-500' : ''}
|
||||
hover:border-success-300 dark:hover:border-success-700 transition-colors cursor-move
|
||||
`}
|
||||
>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-success-600 dark:text-success-400 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3 h-3" />
|
||||
Approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export default function SiteDashboard() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate(`/sites/${siteId}/publishing-queue`)}
|
||||
onClick={() => navigate(`/publisher/content-calendar`)}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-amber-500 hover:shadow-lg transition-all group h-auto justify-start"
|
||||
@@ -353,8 +353,8 @@ export default function SiteDashboard() {
|
||||
<ClockIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Publishing Queue</h4>
|
||||
<p className="text-sm text-gray-600">View scheduled content</p>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Content Calendar</h4>
|
||||
<p className="text-sm text-gray-600">Schedule and manage content publishing</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-warning-500 transition" />
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Phase 7: Advanced Site Management
|
||||
* Features: SEO (meta tags, Open Graph, schema.org), Industry & Sectors Configuration
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '../../services/api';
|
||||
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
||||
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
||||
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon } from '../../icons';
|
||||
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon } from '../../icons';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
|
||||
@@ -49,9 +49,9 @@ export default function SiteSettings() {
|
||||
const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false);
|
||||
const siteSelectorRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Check for tab parameter in URL
|
||||
const initialTab = (searchParams.get('tab') as 'general' | 'integrations' | 'publishing' | 'content-types') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'publishing' | 'content-types'>(initialTab);
|
||||
// Check for tab parameter in URL - now includes content-generation and image-settings tabs
|
||||
const initialTab = (searchParams.get('tab') as 'general' | 'content-generation' | 'image-settings' | 'integrations' | 'publishing' | 'content-types') || 'general';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'content-generation' | 'image-settings' | 'integrations' | 'publishing' | 'content-types'>(initialTab);
|
||||
const [contentTypes, setContentTypes] = useState<any>(null);
|
||||
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
||||
|
||||
@@ -60,6 +60,79 @@ export default function SiteSettings() {
|
||||
const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false);
|
||||
const [publishingSettingsSaving, setPublishingSettingsSaving] = useState(false);
|
||||
|
||||
// Content Generation Settings state
|
||||
const [contentGenerationSettings, setContentGenerationSettings] = useState({
|
||||
appendToPrompt: '',
|
||||
defaultTone: 'professional',
|
||||
defaultLength: 'medium',
|
||||
});
|
||||
const [contentGenerationLoading, setContentGenerationLoading] = useState(false);
|
||||
const [contentGenerationSaving, setContentGenerationSaving] = useState(false);
|
||||
|
||||
// Image Settings state
|
||||
const [imageQuality, setImageQuality] = useState<'standard' | 'premium' | 'best'>('premium');
|
||||
const [imageSettings, setImageSettings] = useState({
|
||||
enabled: true,
|
||||
service: 'openai' as 'openai' | 'runware',
|
||||
provider: 'openai',
|
||||
model: 'dall-e-3',
|
||||
image_type: 'realistic' as 'realistic' | 'artistic' | 'cartoon',
|
||||
max_in_article_images: 2,
|
||||
image_format: 'webp' as 'webp' | 'jpg' | 'png',
|
||||
desktop_enabled: true,
|
||||
mobile_enabled: true,
|
||||
featured_image_size: '1024x1024',
|
||||
desktop_image_size: '1024x1024',
|
||||
});
|
||||
const [imageSettingsLoading, setImageSettingsLoading] = useState(false);
|
||||
const [imageSettingsSaving, setImageSettingsSaving] = useState(false);
|
||||
|
||||
// Image quality to config mapping
|
||||
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
||||
standard: { service: 'openai', model: 'dall-e-2' },
|
||||
premium: { service: 'openai', model: 'dall-e-3' },
|
||||
best: { service: 'runware', model: 'runware:97@1' },
|
||||
};
|
||||
|
||||
const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => {
|
||||
if (service === 'runware') return 'best';
|
||||
if (model === 'dall-e-3') return 'premium';
|
||||
return 'standard';
|
||||
};
|
||||
|
||||
const getImageSizes = (provider: string, model: string) => {
|
||||
if (provider === 'runware') {
|
||||
return [
|
||||
{ value: '1280x832', label: '1280×832 pixels' },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
{ value: '512x512', label: '512×512 pixels' },
|
||||
];
|
||||
} else if (provider === 'openai') {
|
||||
if (model === 'dall-e-2') {
|
||||
return [
|
||||
{ value: '256x256', label: '256×256 pixels' },
|
||||
{ value: '512x512', label: '512×512 pixels' },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
];
|
||||
} else if (model === 'dall-e-3') {
|
||||
return [
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
];
|
||||
}
|
||||
}
|
||||
return [{ value: '1024x1024', label: '1024×1024 pixels' }];
|
||||
};
|
||||
|
||||
const getCurrentImageConfig = useCallback(() => {
|
||||
const config = QUALITY_TO_CONFIG[imageQuality];
|
||||
return { service: config.service, model: config.model };
|
||||
}, [imageQuality]);
|
||||
|
||||
const availableImageSizes = getImageSizes(
|
||||
getCurrentImageConfig().service,
|
||||
getCurrentImageConfig().model
|
||||
);
|
||||
|
||||
// Sectors selection state
|
||||
const [industries, setIndustries] = useState<Industry[]>([]);
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
||||
@@ -111,7 +184,7 @@ export default function SiteSettings() {
|
||||
useEffect(() => {
|
||||
// Update tab if URL parameter changes
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab && ['general', 'integrations', 'publishing', 'content-types'].includes(tab)) {
|
||||
if (tab && ['general', 'content-generation', 'image-settings', 'integrations', 'publishing', 'content-types'].includes(tab)) {
|
||||
setActiveTab(tab as typeof activeTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
@@ -128,6 +201,49 @@ export default function SiteSettings() {
|
||||
}
|
||||
}, [activeTab, siteId]);
|
||||
|
||||
// Load content generation settings when tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'content-generation' && siteId) {
|
||||
loadContentGenerationSettings();
|
||||
}
|
||||
}, [activeTab, siteId]);
|
||||
|
||||
// Load image settings when tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'image-settings' && siteId) {
|
||||
loadImageSettings();
|
||||
}
|
||||
}, [activeTab, siteId]);
|
||||
|
||||
// Update image sizes when quality changes
|
||||
useEffect(() => {
|
||||
const config = getCurrentImageConfig();
|
||||
const sizes = getImageSizes(config.service, config.model);
|
||||
const defaultSize = sizes.length > 0 ? sizes[0].value : '1024x1024';
|
||||
|
||||
const validSizes = sizes.map(s => s.value);
|
||||
const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size);
|
||||
const needsDesktopUpdate = !validSizes.includes(imageSettings.desktop_image_size);
|
||||
|
||||
if (needsFeaturedUpdate || needsDesktopUpdate) {
|
||||
setImageSettings(prev => ({
|
||||
...prev,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size,
|
||||
desktop_image_size: needsDesktopUpdate ? defaultSize : prev.desktop_image_size,
|
||||
}));
|
||||
} else {
|
||||
setImageSettings(prev => ({
|
||||
...prev,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
}));
|
||||
}
|
||||
}, [imageQuality, getCurrentImageConfig]);
|
||||
|
||||
// Load sites for selector
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
@@ -253,6 +369,109 @@ export default function SiteSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Content Generation Settings
|
||||
const loadContentGenerationSettings = async () => {
|
||||
try {
|
||||
setContentGenerationLoading(true);
|
||||
const contentData = await fetchAPI('/v1/system/settings/content/content_generation/');
|
||||
if (contentData?.config) {
|
||||
setContentGenerationSettings({
|
||||
appendToPrompt: contentData.config.append_to_prompt || '',
|
||||
defaultTone: contentData.config.default_tone || 'professional',
|
||||
defaultLength: contentData.config.default_length || 'medium',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Content generation settings not found, using defaults');
|
||||
} finally {
|
||||
setContentGenerationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveContentGenerationSettings = async () => {
|
||||
try {
|
||||
setContentGenerationSaving(true);
|
||||
await fetchAPI('/v1/system/settings/content/content_generation/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
append_to_prompt: contentGenerationSettings.appendToPrompt,
|
||||
default_tone: contentGenerationSettings.defaultTone,
|
||||
default_length: contentGenerationSettings.defaultLength,
|
||||
}
|
||||
}),
|
||||
});
|
||||
toast.success('Content generation settings saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving content generation settings:', error);
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
} finally {
|
||||
setContentGenerationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Image Settings
|
||||
const loadImageSettings = async () => {
|
||||
try {
|
||||
setImageSettingsLoading(true);
|
||||
const imageData = await fetchAPI('/v1/system/settings/integrations/image_generation/');
|
||||
if (imageData) {
|
||||
const quality = getQualityFromConfig(imageData.service || imageData.provider, imageData.model);
|
||||
setImageQuality(quality);
|
||||
|
||||
setImageSettings({
|
||||
enabled: imageData.enabled !== false,
|
||||
service: imageData.service || imageData.provider || 'openai',
|
||||
provider: imageData.provider || imageData.service || 'openai',
|
||||
model: imageData.model || 'dall-e-3',
|
||||
image_type: imageData.image_type || 'realistic',
|
||||
max_in_article_images: imageData.max_in_article_images || 2,
|
||||
image_format: imageData.image_format || 'webp',
|
||||
desktop_enabled: imageData.desktop_enabled !== false,
|
||||
mobile_enabled: imageData.mobile_enabled !== false,
|
||||
featured_image_size: imageData.featured_image_size || '1024x1024',
|
||||
desktop_image_size: imageData.desktop_image_size || '1024x1024',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error loading image settings:', error);
|
||||
} finally {
|
||||
setImageSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveImageSettings = async () => {
|
||||
try {
|
||||
setImageSettingsSaving(true);
|
||||
const config = getCurrentImageConfig();
|
||||
const configToSave = {
|
||||
enabled: imageSettings.enabled,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
runwareModel: config.service === 'runware' ? config.model : undefined,
|
||||
image_type: imageSettings.image_type,
|
||||
max_in_article_images: imageSettings.max_in_article_images,
|
||||
image_format: imageSettings.image_format,
|
||||
desktop_enabled: imageSettings.desktop_enabled,
|
||||
mobile_enabled: imageSettings.mobile_enabled,
|
||||
featured_image_size: imageSettings.featured_image_size,
|
||||
desktop_image_size: imageSettings.desktop_image_size,
|
||||
};
|
||||
|
||||
await fetchAPI('/v1/system/settings/integrations/image_generation/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(configToSave),
|
||||
});
|
||||
toast.success('Image settings saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving image settings:', error);
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
} finally {
|
||||
setImageSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadIndustries = async () => {
|
||||
try {
|
||||
const response = await fetchIndustries();
|
||||
@@ -609,14 +828,14 @@ export default function SiteSettings() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-4 overflow-x-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('general');
|
||||
navigate(`/sites/${siteId}/settings`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors ${
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'general'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
@@ -625,13 +844,43 @@ export default function SiteSettings() {
|
||||
>
|
||||
General
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('content-generation');
|
||||
navigate(`/sites/${siteId}/settings?tab=content-generation`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'content-generation'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
startIcon={<FileTextIcon className="w-4 h-4" />}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('image-settings');
|
||||
navigate(`/sites/${siteId}/settings?tab=image-settings`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'image-settings'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
startIcon={<ImageIcon className="w-4 h-4" />}
|
||||
>
|
||||
Images
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab('integrations');
|
||||
navigate(`/sites/${siteId}/settings?tab=integrations`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors ${
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'integrations'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
@@ -646,7 +895,7 @@ export default function SiteSettings() {
|
||||
setActiveTab('publishing');
|
||||
navigate(`/sites/${siteId}/settings?tab=publishing`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors ${
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'publishing'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
@@ -662,7 +911,7 @@ export default function SiteSettings() {
|
||||
setActiveTab('content-types');
|
||||
navigate(`/sites/${siteId}/settings?tab=content-types`, { replace: true });
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors ${
|
||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||
activeTab === 'content-types'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
@@ -675,6 +924,241 @@ export default function SiteSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Generation Tab */}
|
||||
{activeTab === 'content-generation' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Content Generation</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Customize how your articles are written</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contentGenerationLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-brand-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-2">Append to Every Prompt</Label>
|
||||
<TextArea
|
||||
value={contentGenerationSettings.appendToPrompt}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, appendToPrompt: value })}
|
||||
placeholder="Add custom instructions that will be included with every content generation request..."
|
||||
rows={5}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
This text will be appended to every AI prompt. Use it to enforce brand guidelines, tone, or specific requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Default Writing Tone</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
{ value: 'conversational', label: 'Conversational' },
|
||||
{ value: 'formal', label: 'Formal' },
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'friendly', label: 'Friendly' },
|
||||
]}
|
||||
value={contentGenerationSettings.defaultTone}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultTone: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Default Article Length</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'short', label: 'Short (500-800 words)' },
|
||||
{ value: 'medium', label: 'Medium (1000-1500 words)' },
|
||||
{ value: 'long', label: 'Long (2000-3000 words)' },
|
||||
{ value: 'comprehensive', label: 'Comprehensive (3000+ words)' },
|
||||
]}
|
||||
value={contentGenerationSettings.defaultLength}
|
||||
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultLength: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={saveContentGenerationSettings}
|
||||
disabled={contentGenerationSaving}
|
||||
startIcon={contentGenerationSaving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{contentGenerationSaving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Settings Tab */}
|
||||
{activeTab === 'image-settings' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Image Generation</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Configure how images are created for your articles</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{imageSettingsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-purple-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Image Quality & Style */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Image Quality</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'standard', label: 'Standard - Fast & economical (DALL·E 2)' },
|
||||
{ value: 'premium', label: 'Premium - High quality (DALL·E 3)' },
|
||||
{ value: 'best', label: 'Best - Highest quality (Runware)' },
|
||||
]}
|
||||
value={imageQuality}
|
||||
onChange={(value) => setImageQuality(value as 'standard' | 'premium' | 'best')}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Higher quality produces better images</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Image Style</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'realistic', label: 'Realistic' },
|
||||
{ value: 'artistic', label: 'Artistic' },
|
||||
{ value: 'cartoon', label: 'Cartoon' },
|
||||
]}
|
||||
value={imageSettings.image_type}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, image_type: value as any })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Choose the visual style that matches your brand</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Featured Image Size */}
|
||||
<div>
|
||||
<Label className="mb-2">Featured Image</Label>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-500 to-brand-500 text-white">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-medium">Featured Image Size</div>
|
||||
<div className="text-xs bg-white/20 px-2 py-1 rounded">Always Enabled</div>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
options={availableImageSizes}
|
||||
value={imageSettings.featured_image_size}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, featured_image_size: value })}
|
||||
className="w-full [&_.igny8-select-styled]:bg-white/10 [&_.igny8-select-styled]:border-white/20 [&_.igny8-select-styled]:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Desktop & Mobile Images */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={imageSettings.desktop_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, desktop_enabled: checked })}
|
||||
/>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Desktop Images</Label>
|
||||
</div>
|
||||
{imageSettings.desktop_enabled && (
|
||||
<SelectDropdown
|
||||
options={availableImageSizes}
|
||||
value={imageSettings.desktop_image_size}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, desktop_image_size: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Checkbox
|
||||
checked={imageSettings.mobile_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, mobile_enabled: checked })}
|
||||
/>
|
||||
<div>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Mobile Images</Label>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">512×512 pixels</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Max Images & Format */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Max In-Article Images</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: '1', label: '1 Image' },
|
||||
{ value: '2', label: '2 Images' },
|
||||
{ value: '3', label: '3 Images' },
|
||||
{ value: '4', label: '4 Images' },
|
||||
{ value: '5', label: '5 Images' },
|
||||
]}
|
||||
value={String(imageSettings.max_in_article_images)}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Image Format</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'webp', label: 'WEBP (recommended)' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
]}
|
||||
value={imageSettings.image_format}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, image_format: value as any })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={saveImageSettings}
|
||||
disabled={imageSettingsSaving}
|
||||
startIcon={imageSettingsSaving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
>
|
||||
{imageSettingsSaving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publishing Tab */}
|
||||
{activeTab === 'publishing' && (
|
||||
<Card>
|
||||
|
||||
@@ -460,7 +460,7 @@
|
||||
----------------------------------------------------------------- */
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3.5 px-4 py-3 font-medium rounded-lg text-theme-sm;
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-1.5 font-medium rounded-lg text-theme-sm;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
@@ -472,7 +472,7 @@
|
||||
}
|
||||
|
||||
@utility menu-item-icon-size {
|
||||
@apply w-6 h-6 flex-shrink-0;
|
||||
@apply w-5 h-5 flex-shrink-0;
|
||||
& svg { width: 100%; height: 100%; }
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
----------------------------------------------------------------- */
|
||||
|
||||
@utility menu-dropdown-item {
|
||||
@apply block px-3.5 py-2.5 text-theme-sm font-medium rounded-md transition-colors;
|
||||
@apply block px-3 py-1.5 text-theme-sm font-medium rounded-md transition-colors;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-active {
|
||||
|
||||
Reference in New Issue
Block a user