From 935c7234b101ee6490491f70cef6abf25936584d Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sat, 3 Jan 2026 04:39:06 +0000 Subject: [PATCH] SEction 2 part 2 --- backend/igny8_core/modules/writer/views.py | 121 ++++ frontend/src/App.tsx | 10 +- .../ui/button-group/ButtonGroup.tsx | 2 +- .../ui/tooltip/CalendarItemTooltip.tsx | 81 +++ frontend/src/components/ui/tooltip/index.ts | 3 +- frontend/src/layout/AppHeader.tsx | 1 + frontend/src/layout/AppSidebar.tsx | 49 +- .../src/pages/Publisher/ContentCalendar.tsx | 683 ++++++++++++++++++ frontend/src/pages/Sites/Dashboard.tsx | 6 +- frontend/src/pages/Sites/Settings.tsx | 506 ++++++++++++- frontend/src/styles/design-system.css | 6 +- 11 files changed, 1424 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx create mode 100644 frontend/src/pages/Publisher/ContentCalendar.tsx diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 7bd27b8a..df767d54 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -1044,6 +1044,127 @@ class ContentViewSet(SiteSectorModelViewSet): request=request ) + @action(detail=True, methods=['post'], url_path='schedule', url_name='schedule', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) + def schedule(self, request, pk=None): + """ + Schedule content for publishing at a specific date/time. + Sets site_status to 'scheduled' and scheduled_publish_at to the provided datetime. + + POST /api/v1/writer/content/{id}/schedule/ + { + "scheduled_publish_at": "2026-01-15T09:00:00Z" // Required: ISO 8601 datetime + } + """ + from django.utils import timezone + from django.utils.dateparse import parse_datetime + import logging + + logger = logging.getLogger(__name__) + content = self.get_object() + + # Validate content status - must be approved to schedule + if content.status != 'approved': + return error_response( + error=f'Only approved content can be scheduled. Current status: {content.status}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Check if already published + if content.site_status == 'published': + return error_response( + error='Content is already published', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get scheduled_publish_at from request + scheduled_at_str = request.data.get('scheduled_publish_at') + if not scheduled_at_str: + return error_response( + error='scheduled_publish_at is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Parse datetime + scheduled_at = parse_datetime(scheduled_at_str) + if not scheduled_at: + return error_response( + error='Invalid datetime format. Use ISO 8601 format (e.g., 2026-01-15T09:00:00Z)', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Ensure datetime is in the future + if scheduled_at <= timezone.now(): + return error_response( + error='Scheduled time must be in the future', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Update content + content.site_status = 'scheduled' + content.scheduled_publish_at = scheduled_at + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at', 'updated_at']) + + logger.info(f"[ContentViewSet.schedule] Content {content.id} scheduled for {scheduled_at}") + + return success_response( + data={ + 'content_id': content.id, + 'site_status': content.site_status, + 'scheduled_publish_at': content.scheduled_publish_at.isoformat(), + }, + message=f'Content scheduled for {scheduled_at.strftime("%Y-%m-%d %H:%M")}', + request=request + ) + + @action(detail=True, methods=['post'], url_path='unschedule', url_name='unschedule', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) + def unschedule(self, request, pk=None): + """ + Remove content from publishing schedule. + Clears site_status and scheduled_publish_at. + + POST /api/v1/writer/content/{id}/unschedule/ + """ + from django.utils import timezone + import logging + + logger = logging.getLogger(__name__) + content = self.get_object() + + # Check if content is scheduled + if content.site_status not in ['scheduled', 'publishing']: + return error_response( + error=f'Content is not scheduled. Current site_status: {content.site_status}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Store old values + old_scheduled_at = content.scheduled_publish_at + + # Clear scheduling + content.site_status = 'not_published' + content.scheduled_publish_at = None + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at', 'updated_at']) + + logger.info(f"[ContentViewSet.unschedule] Content {content.id} removed from schedule (was {old_scheduled_at})") + + return success_response( + data={ + 'content_id': content.id, + 'site_status': content.site_status, + 'was_scheduled_for': old_scheduled_at.isoformat() if old_scheduled_at else None, + }, + message='Content removed from publishing schedule', + request=request + ) + @action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts') def generate_image_prompts(self, request): """Generate image prompts for content records - same pattern as other AI functions""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bf65152..1ff82ec7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 */} } /> + {/* Publisher Module - Content Calendar */} + } /> + } /> + {/* Linker Module - Redirect dashboard to content */} } /> } /> @@ -272,7 +279,8 @@ export default function App() { } /> } /> } /> - } /> + {/* Legacy redirect - Publishing Queue moved to Content Calendar */} + } /> } /> } /> diff --git a/frontend/src/components/ui/button-group/ButtonGroup.tsx b/frontend/src/components/ui/button-group/ButtonGroup.tsx index f3f2eb73..db16a26d 100644 --- a/frontend/src/components/ui/button-group/ButtonGroup.tsx +++ b/frontend/src/components/ui/button-group/ButtonGroup.tsx @@ -41,7 +41,7 @@ export const ButtonGroupItem: React.FC = ({ 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" diff --git a/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx b/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx new file mode 100644 index 00000000..f5d632e3 --- /dev/null +++ b/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx @@ -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 = ({ + children, + title, + status, + contentType, + date, + dateLabel = 'Date', + wordCount, + placement = 'top', +}) => { + const config = statusConfig[status]; + + const tooltipContent = ( +
+
+ {config.icon} {config.label} +
+
+ {title} +
+
+ {contentType &&
Type: {contentType}
} + {date && ( +
+ {dateLabel}: {new Date(date).toLocaleString()} +
+ )} + {wordCount !== undefined &&
Words: {wordCount}
} +
+
+ ); + + return ( + + {children} + + ); +}; + +export default CalendarItemTooltip; diff --git a/frontend/src/components/ui/tooltip/index.ts b/frontend/src/components/ui/tooltip/index.ts index 54293cb1..e200e81a 100644 --- a/frontend/src/components/ui/tooltip/index.ts +++ b/frontend/src/components/ui/tooltip/index.ts @@ -1,2 +1,3 @@ export { Tooltip } from "./Tooltip"; - +export { EnhancedTooltip } from "./EnhancedTooltip"; +export { CalendarItemTooltip } from "./CalendarItemTooltip"; diff --git a/frontend/src/layout/AppHeader.tsx b/frontend/src/layout/AppHeader.tsx index ef37e216..00eb4d43 100644 --- a/frontend/src/layout/AppHeader.tsx +++ b/frontend/src/layout/AppHeader.tsx @@ -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 ]; diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index c691e5b5..1935f98a 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -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: , - 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: , + 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) => ( -
  • + .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 ( +
  • {nav.subItems ? (
  • - ))} + ); + })} ); @@ -497,7 +498,7 @@ const AppSidebar: React.FC = () => {