Pre luanch plan phase 1 complete
This commit is contained in:
@@ -25,18 +25,15 @@ const Privacy = lazy(() => import("./pages/legal/Privacy"));
|
||||
const Home = lazy(() => import("./pages/Dashboard/Home"));
|
||||
|
||||
// Planner Module - Lazy loaded
|
||||
const PlannerDashboard = lazy(() => import("./pages/Planner/Dashboard"));
|
||||
const Keywords = lazy(() => import("./pages/Planner/Keywords"));
|
||||
const Clusters = lazy(() => import("./pages/Planner/Clusters"));
|
||||
const ClusterDetail = lazy(() => import("./pages/Planner/ClusterDetail"));
|
||||
const Ideas = lazy(() => import("./pages/Planner/Ideas"));
|
||||
|
||||
// Writer Module - Lazy loaded
|
||||
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
||||
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
||||
const Content = lazy(() => import("./pages/Writer/Content"));
|
||||
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
|
||||
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||
const Review = lazy(() => import("./pages/Writer/Review"));
|
||||
const Approved = lazy(() => import("./pages/Writer/Approved"));
|
||||
@@ -45,16 +42,13 @@ const Approved = lazy(() => import("./pages/Writer/Approved"));
|
||||
const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage"));
|
||||
|
||||
// Linker Module - Lazy loaded
|
||||
const LinkerDashboard = lazy(() => import("./pages/Linker/Dashboard"));
|
||||
const LinkerContentList = lazy(() => import("./pages/Linker/ContentList"));
|
||||
|
||||
// Optimizer Module - Lazy loaded
|
||||
const OptimizerDashboard = lazy(() => import("./pages/Optimizer/Dashboard"));
|
||||
const OptimizerContentSelector = lazy(() => import("./pages/Optimizer/ContentSelector"));
|
||||
const AnalysisPreview = lazy(() => import("./pages/Optimizer/AnalysisPreview"));
|
||||
|
||||
// Thinker Module - Lazy loaded
|
||||
const ThinkerDashboard = lazy(() => import("./pages/Thinker/Dashboard"));
|
||||
// Thinker Module - Lazy loaded (Admin Only)
|
||||
const Prompts = lazy(() => import("./pages/Thinker/Prompts"));
|
||||
const AuthorProfiles = lazy(() => import("./pages/Thinker/AuthorProfiles"));
|
||||
const ThinkerProfile = lazy(() => import("./pages/Thinker/Profile"));
|
||||
@@ -104,7 +98,6 @@ const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
|
||||
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
|
||||
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"));
|
||||
|
||||
@@ -376,9 +376,8 @@ export default function SignUpFormEnhanced({ planDetails: planDetailsProp, planL
|
||||
</div>
|
||||
|
||||
{isPaidPlan ? (
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="w-full">
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="w-full" endIcon={<ChevronRightIcon className="w-4 h-4" />}>
|
||||
Continue to Billing
|
||||
<ChevronRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" disabled={loading} className="w-full">
|
||||
@@ -401,9 +400,8 @@ export default function SignUpFormEnhanced({ planDetails: planDetailsProp, planL
|
||||
<Button type="button" variant="outline" onClick={handlePrevStep} className="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="flex-1">
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="flex-1" endIcon={<ChevronRightIcon className="w-4 h-4" />}>
|
||||
Continue to Payment
|
||||
<ChevronRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
31
frontend/src/components/common/PageLoader.tsx
Normal file
31
frontend/src/components/common/PageLoader.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Page Loader Component - Global page loading indicator
|
||||
* Displays a consistent spinner overlay when pages are loading
|
||||
* Used in AppLayout to provide unified loading experience across all pages
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Spinner } from '../ui/spinner/Spinner';
|
||||
import { usePageLoadingContext } from '../../context/PageLoadingContext';
|
||||
|
||||
interface PageLoaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageLoader: React.FC<PageLoaderProps> = ({ className = '' }) => {
|
||||
const { isLoading, loadingMessage } = usePageLoadingContext();
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-20 ${className}`}>
|
||||
<Spinner size="lg" color="primary" />
|
||||
{loadingMessage && (
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{loadingMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoader;
|
||||
@@ -52,8 +52,8 @@ export default function SiteCard({
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
|
||||
<div className="relative p-5 pb-9">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="relative p-3 pb-5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="inline-flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
@@ -61,32 +61,34 @@ export default function SiteCard({
|
||||
{site.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{site.description || 'No description'}
|
||||
</p>
|
||||
{site.domain && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2 ml-8">
|
||||
{site.domain}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<SiteTypeBadge hostingType={site.hosting_type} />
|
||||
{site.description && (
|
||||
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{site.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
<SiteTypeBadge hostingType={site.hosting_type} size="xs" />
|
||||
{site.industry_name && (
|
||||
<Badge variant="soft" color="warning" size="sm">
|
||||
<Badge variant="soft" color="warning" size="xs">
|
||||
{site.industry_name}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="soft" color="neutral" size="sm">
|
||||
<Badge variant="soft" color="neutral" size="xs">
|
||||
{site.active_sectors_count} / 5 Sectors
|
||||
</Badge>
|
||||
{site.status && (
|
||||
<Badge variant={site.is_active ? 'solid' : 'soft'} color={site.status === 'active' ? 'success' : 'neutral'} size="sm">
|
||||
<Badge variant={site.is_active ? 'solid' : 'soft'} color={site.status === 'active' ? 'success' : 'neutral'} size="xs">
|
||||
{site.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Setup Checklist - Compact View */}
|
||||
<div className="mt-3">
|
||||
<div className="mt-2">
|
||||
<SiteSetupChecklist
|
||||
siteId={site.id}
|
||||
siteName={site.name}
|
||||
@@ -98,18 +100,18 @@ export default function SiteCard({
|
||||
/>
|
||||
</div>
|
||||
{/* Status Text and Circle - Same row */}
|
||||
<div className="absolute top-5 right-5 flex items-center gap-2">
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
|
||||
{statusText.text}
|
||||
</span>
|
||||
<div
|
||||
className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
|
||||
className={`w-[20px] h-[20px] rounded-full ${getStatusColor()} transition-colors duration-200`}
|
||||
title={site.is_active ? 'Active site' : 'Inactive site'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-200 p-4 dark:border-gray-800">
|
||||
<div className="flex gap-2 flex-1">
|
||||
<div className="flex items-center justify-between border-t border-gray-200 p-2 dark:border-gray-800">
|
||||
<div className="flex gap-1.5 flex-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
|
||||
@@ -9,7 +9,7 @@ import Badge from '../ui/badge/Badge';
|
||||
interface SiteTypeBadgeProps {
|
||||
hostingType: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function SiteTypeBadge({ hostingType, className = '', size = 'sm' }: SiteTypeBadgeProps) {
|
||||
|
||||
74
frontend/src/context/PageLoadingContext.tsx
Normal file
74
frontend/src/context/PageLoadingContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Page Loading Context - Global page loading state management
|
||||
* Provides a centralized loading indicator for page transitions and data fetching
|
||||
* Pages can use usePageLoading hook to show/hide the global spinner
|
||||
*/
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useRef } from 'react';
|
||||
|
||||
interface PageLoadingContextType {
|
||||
isLoading: boolean;
|
||||
loadingMessage: string | null;
|
||||
setLoading: (loading: boolean, message?: string) => void;
|
||||
startLoading: (message?: string) => void;
|
||||
stopLoading: () => void;
|
||||
}
|
||||
|
||||
const PageLoadingContext = createContext<PageLoadingContextType | undefined>(undefined);
|
||||
|
||||
export function PageLoadingProvider({ children }: { children: ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState<string | null>(null);
|
||||
const loadingCountRef = useRef(0);
|
||||
|
||||
const setLoading = useCallback((loading: boolean, message?: string) => {
|
||||
if (loading) {
|
||||
loadingCountRef.current++;
|
||||
setIsLoading(true);
|
||||
if (message) setLoadingMessage(message);
|
||||
} else {
|
||||
loadingCountRef.current = Math.max(0, loadingCountRef.current - 1);
|
||||
if (loadingCountRef.current === 0) {
|
||||
setIsLoading(false);
|
||||
setLoadingMessage(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startLoading = useCallback((message?: string) => {
|
||||
setLoading(true, message);
|
||||
}, [setLoading]);
|
||||
|
||||
const stopLoading = useCallback(() => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
|
||||
return (
|
||||
<PageLoadingContext.Provider value={{ isLoading, loadingMessage, setLoading, startLoading, stopLoading }}>
|
||||
{children}
|
||||
</PageLoadingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageLoadingContext() {
|
||||
const context = useContext(PageLoadingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePageLoadingContext must be used within a PageLoadingProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for pages to manage loading state
|
||||
* Automatically tracks loading state and provides helper functions
|
||||
*/
|
||||
export function usePageLoading() {
|
||||
const { isLoading, loadingMessage, startLoading, stopLoading, setLoading } = usePageLoadingContext();
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
setLoading,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { useHeaderMetrics } from "../context/HeaderMetricsContext";
|
||||
import { useErrorHandler } from "../hooks/useErrorHandler";
|
||||
import { trackLoading } from "../components/common/LoadingStateMonitor";
|
||||
import PendingPaymentBanner from "../components/billing/PendingPaymentBanner";
|
||||
import { PageLoadingProvider, usePageLoadingContext } from "../context/PageLoadingContext";
|
||||
import PageLoader from "../components/common/PageLoader";
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
@@ -139,17 +141,9 @@ const LayoutContent: React.FC = () => {
|
||||
accentColor = 'purple';
|
||||
}
|
||||
|
||||
// Format credit value with K/M suffix for large numbers
|
||||
// Format credit value with full number and comma separators
|
||||
const formatCredits = (val: number): string => {
|
||||
if (val >= 1000000) {
|
||||
const millions = val / 1000000;
|
||||
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
|
||||
}
|
||||
if (val >= 1000) {
|
||||
const thousands = val / 1000;
|
||||
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
|
||||
}
|
||||
return val.toString();
|
||||
return val.toLocaleString();
|
||||
};
|
||||
|
||||
// Set credit balance - show as "used/total Credits"
|
||||
@@ -174,18 +168,33 @@ const LayoutContent: React.FC = () => {
|
||||
<AppHeader />
|
||||
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
|
||||
<PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
|
||||
<div className="px-4 pt-1.5 pb-20 md:px-6 md:pt-1.5 md:pb-24">
|
||||
<Outlet />
|
||||
<div className="p-3">
|
||||
<PageLoaderWrapper>
|
||||
<Outlet />
|
||||
</PageLoaderWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper component to conditionally render Outlet or PageLoader
|
||||
const PageLoaderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isLoading } = usePageLoadingContext();
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<LayoutContent />
|
||||
<PageLoadingProvider>
|
||||
<LayoutContent />
|
||||
</PageLoadingProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
import ComponentCard from "../components/common/ComponentCard";
|
||||
|
||||
export default function Analytics() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Analytics - IGNY8" description="Performance analytics" />
|
||||
|
||||
<ComponentCard title="Coming Soon" desc="Performance analytics">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Analytics - Coming Soon
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Performance analytics and reporting for data-driven decisions
|
||||
</p>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* Automation Dashboard Page
|
||||
* Main page for managing AI automation pipeline
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { automationService, AutomationRun, AutomationConfig, PipelineStage } from '../../services/automationService';
|
||||
import StageCard from '../../components/Automation/StageCard';
|
||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
import RunHistory from '../../components/Automation/RunHistory';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { BoltIcon } from '../../icons';
|
||||
|
||||
const STAGE_NAMES = [
|
||||
'Keywords → Clusters',
|
||||
'Clusters → Ideas',
|
||||
'Ideas → Tasks',
|
||||
'Tasks → Content',
|
||||
'Content → Image Prompts',
|
||||
'Image Prompts → Images',
|
||||
'Manual Review Gate',
|
||||
];
|
||||
|
||||
const AutomationPage: React.FC = () => {
|
||||
const { activeSite } = useSiteStore();
|
||||
const toast = useToast();
|
||||
const [config, setConfig] = useState<AutomationConfig | null>(null);
|
||||
const [currentRun, setCurrentRun] = useState<AutomationRun | null>(null);
|
||||
const [pipelineOverview, setPipelineOverview] = useState<PipelineStage[]>([]);
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null);
|
||||
|
||||
// Poll for current run updates
|
||||
useEffect(() => {
|
||||
if (!activeSite) return;
|
||||
|
||||
loadData();
|
||||
|
||||
// Poll every 5 seconds when run is active
|
||||
const interval = setInterval(() => {
|
||||
if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) {
|
||||
loadCurrentRun();
|
||||
} else {
|
||||
// Refresh pipeline overview when not running
|
||||
loadPipelineOverview();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSite, currentRun?.status]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [configData, runData, estimateData, pipelineData] = await Promise.all([
|
||||
automationService.getConfig(activeSite.id),
|
||||
automationService.getCurrentRun(activeSite.id),
|
||||
automationService.estimate(activeSite.id),
|
||||
automationService.getPipelineOverview(activeSite.id),
|
||||
]);
|
||||
setConfig(configData);
|
||||
setCurrentRun(runData.run);
|
||||
setEstimate(estimateData);
|
||||
setPipelineOverview(pipelineData.stages);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load automation data');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentRun = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
const data = await automationService.getCurrentRun(activeSite.id);
|
||||
setCurrentRun(data.run);
|
||||
} catch (error) {
|
||||
console.error('Failed to poll current run', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPipelineOverview = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
const data = await automationService.getPipelineOverview(activeSite.id);
|
||||
setPipelineOverview(data.stages);
|
||||
} catch (error) {
|
||||
console.error('Failed to poll pipeline overview', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunNow = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
// Check credit balance
|
||||
if (estimate && !estimate.sufficient) {
|
||||
toast.error(`Insufficient credits. Need ~${estimate.estimated_credits}, you have ${estimate.current_balance}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await automationService.runNow(activeSite.id);
|
||||
toast.success('Automation started');
|
||||
loadCurrentRun();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to start automation');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!currentRun) return;
|
||||
|
||||
try {
|
||||
await automationService.pause(currentRun.run_id);
|
||||
toast.success('Automation paused');
|
||||
loadCurrentRun();
|
||||
} catch (error) {
|
||||
toast.error('Failed to pause automation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!currentRun) return;
|
||||
|
||||
try {
|
||||
await automationService.resume(currentRun.run_id);
|
||||
toast.success('Automation resumed');
|
||||
loadCurrentRun();
|
||||
} catch (error) {
|
||||
toast.error('Failed to resume automation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async (newConfig: Partial<AutomationConfig>) => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
await automationService.updateConfig(activeSite.id, newConfig);
|
||||
toast.success('Configuration saved');
|
||||
setShowConfigModal(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to save configuration');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">Loading automation...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">Please select a site to view automation</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="AI Automation Pipeline | IGNY8"
|
||||
description="Automated content creation from keywords to published articles"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Page Header with Site Selector (no sector) */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-purple-600 dark:bg-purple-500 flex-shrink-0">
|
||||
<BoltIcon className="text-white size-5" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">AI Automation Pipeline</h2>
|
||||
</div>
|
||||
{activeSite && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<DebugSiteSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Status Card */}
|
||||
{config && (
|
||||
<ComponentCard
|
||||
title="Schedule & Status"
|
||||
desc="Configure and monitor your automation schedule"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Status</div>
|
||||
<div className="font-semibold mt-1">
|
||||
{config.is_enabled ? (
|
||||
<span className="text-success-600 dark:text-success-400">● Enabled</span>
|
||||
) : (
|
||||
<span className="text-gray-600 dark:text-gray-400">○ Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Schedule</div>
|
||||
<div className="font-semibold mt-1 capitalize">
|
||||
{config.frequency} at {config.scheduled_time}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Last Run</div>
|
||||
<div className="font-semibold mt-1">
|
||||
{config.last_run_at
|
||||
? new Date(config.last_run_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Estimated Credits</div>
|
||||
<div className="font-semibold mt-1">
|
||||
{estimate?.estimated_credits || 0} credits
|
||||
{estimate && !estimate.sufficient && (
|
||||
<span className="text-error-600 dark:text-error-400 ml-2">(Insufficient)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
onClick={() => setShowConfigModal(true)}
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
{currentRun?.status === 'running' && (
|
||||
<Button
|
||||
onClick={handlePause}
|
||||
variant="primary"
|
||||
tone="warning"
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
{currentRun?.status === 'paused' && (
|
||||
<Button
|
||||
onClick={handleResume}
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
{!currentRun && (
|
||||
<Button
|
||||
onClick={handleRunNow}
|
||||
variant="primary"
|
||||
tone="success"
|
||||
disabled={!config?.is_enabled}
|
||||
>
|
||||
Run Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Pipeline Overview - Always Visible */}
|
||||
<ComponentCard
|
||||
title="📊 Pipeline Overview"
|
||||
desc="Complete view of automation pipeline status and pending items"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{currentRun ? (
|
||||
<>
|
||||
<span className="font-semibold text-brand-600 dark:text-brand-400">● Live Run Active</span> - Stage {currentRun.current_stage} of 7
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Pipeline Status</span> - Ready to run
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{pipelineOverview.reduce((sum, stage) => sum + stage.pending, 0)} total items pending
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-7 gap-3">
|
||||
{STAGE_NAMES.map((name, index) => (
|
||||
<StageCard
|
||||
key={index}
|
||||
stageNumber={index + 1}
|
||||
stageName={name}
|
||||
currentStage={currentRun?.current_stage || 0}
|
||||
result={currentRun ? (currentRun[`stage_${index + 1}_result` as keyof AutomationRun] as any) : null}
|
||||
pipelineData={pipelineOverview[index]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Current Run Status */}
|
||||
{currentRun && (
|
||||
<ComponentCard
|
||||
title={`🔄 Current Run: ${currentRun.run_id}`}
|
||||
desc="Live automation progress and detailed results"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Status</div>
|
||||
<div className="font-semibold mt-1 capitalize">
|
||||
{currentRun.status === 'running' && <span className="text-brand-600 dark:text-brand-400">● {currentRun.status}</span>}
|
||||
{currentRun.status === 'paused' && <span className="text-warning-600 dark:text-warning-400">⏸ {currentRun.status}</span>}
|
||||
{currentRun.status === 'completed' && <span className="text-success-600 dark:text-success-400">✓ {currentRun.status}</span>}
|
||||
{currentRun.status === 'failed' && <span className="text-error-600 dark:text-error-400">✗ {currentRun.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Current Stage</div>
|
||||
<div className="font-semibold mt-1">
|
||||
Stage {currentRun.current_stage}: {STAGE_NAMES[currentRun.current_stage - 1]}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Started</div>
|
||||
<div className="font-semibold mt-1">
|
||||
{new Date(currentRun.started_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Credits Used</div>
|
||||
<div className="font-semibold mt-1 text-purple-600 dark:text-purple-400">{currentRun.total_credits_used}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomationPage;
|
||||
@@ -7,11 +7,12 @@ import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { ArrowUpIcon } from '../../icons';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function Credits() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [balance, setBalance] = useState<CreditBalance | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadBalance();
|
||||
@@ -19,29 +20,18 @@ export default function Credits() {
|
||||
|
||||
const loadBalance = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading content usage...');
|
||||
const data = await getCreditBalance();
|
||||
setBalance(data);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load content usage: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Usage" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Content Usage" />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -126,7 +116,7 @@ export default function Credits() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { getCreditTransactions, CreditTransaction } from '../../services/billing.api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function Transactions() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
@@ -18,7 +19,7 @@ export default function Transactions() {
|
||||
|
||||
const loadTransactions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading transactions...');
|
||||
const response = await getCreditTransactions();
|
||||
setTransactions(response.results || []);
|
||||
const count = response.count || 0;
|
||||
@@ -26,7 +27,7 @@ export default function Transactions() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load transactions: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,47 +48,42 @@ export default function Transactions() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Credit Transactions" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Transactions</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View all credit transactions and history</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Date</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Amount</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Reference</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((transaction) => (
|
||||
<tr key={transaction.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{new Date(transaction.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={getTransactionTypeColor(transaction.transaction_type) as any}>
|
||||
{transaction.transaction_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={`py-3 px-4 text-sm font-medium ${
|
||||
transaction.amount >= 0
|
||||
? 'text-success-600 dark:text-success-400'
|
||||
: 'text-error-600 dark:text-error-400'
|
||||
}`}>
|
||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount.toLocaleString()}
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Date</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Amount</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Reference</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((transaction) => (
|
||||
<tr key={transaction.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{new Date(transaction.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={getTransactionTypeColor(transaction.transaction_type) as any}>
|
||||
{transaction.transaction_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={`py-3 px-4 text-sm font-medium ${
|
||||
transaction.amount >= 0
|
||||
? 'text-success-600 dark:text-success-400'
|
||||
: 'text-error-600 dark:text-error-400'
|
||||
}`}>
|
||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{transaction.reference_id || '-'}
|
||||
@@ -101,8 +97,7 @@ export default function Transactions() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { getCreditTransactions, getCreditBalance, CreditTransaction as BillingTransaction, CreditBalance } from '../../services/billing.api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
// Credit costs per operation (Phase 0: Credit-only system)
|
||||
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
|
||||
@@ -20,9 +21,9 @@ const CREDIT_COSTS: Record<string, { cost: number | string; description: string
|
||||
|
||||
export default function Usage() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||
const [balance, setBalance] = useState<CreditBalance | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
@@ -30,7 +31,7 @@ export default function Usage() {
|
||||
|
||||
const loadUsage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading usage data...');
|
||||
const [txnData, balanceData] = await Promise.all([
|
||||
getCreditTransactions(),
|
||||
getCreditBalance()
|
||||
@@ -40,23 +41,12 @@ export default function Usage() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage data: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Usage" description="Monitor your credit usage" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Usage" description="Monitor your credit usage and account limits" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Usage & Activity</h1>
|
||||
@@ -175,6 +165,6 @@ export default function Usage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
|
||||
export default function Blank() {
|
||||
return (
|
||||
<div>
|
||||
<PageMeta
|
||||
title="React.js Blank Dashboard | TailAdmin - Next.js Admin Dashboard Template"
|
||||
description="This is React.js Blank Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="min-h-screen rounded-2xl border border-gray-200 bg-white px-5 py-7 dark:border-gray-800 dark:bg-white/[0.03] xl:px-10 xl:py-12">
|
||||
<div className="mx-auto w-full max-w-[630px] text-center">
|
||||
<h3 className="mb-4 font-semibold text-gray-800 text-theme-xl dark:text-white/90 sm:text-2xl">
|
||||
Card Title Here
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 sm:text-base">
|
||||
Start putting content on grids or panels, you can also use different
|
||||
combinations of grids.Please check out the dashboard and other pages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { EventInput, DateSelectArg, EventClickArg } from "@fullcalendar/core";
|
||||
import { Modal } from "../components/ui/modal";
|
||||
import { useModal } from "../hooks/useModal";
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
import Button from "../components/ui/button/Button";
|
||||
import InputField from "../components/form/input/InputField";
|
||||
|
||||
interface CalendarEvent extends EventInput {
|
||||
extendedProps: {
|
||||
calendar: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Calendar: React.FC = () => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(
|
||||
null
|
||||
);
|
||||
const [eventTitle, setEventTitle] = useState("");
|
||||
const [eventStartDate, setEventStartDate] = useState("");
|
||||
const [eventEndDate, setEventEndDate] = useState("");
|
||||
const [eventLevel, setEventLevel] = useState("");
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
|
||||
const calendarsEvents = {
|
||||
Danger: "danger",
|
||||
Success: "success",
|
||||
Primary: "primary",
|
||||
Warning: "warning",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with some events
|
||||
setEvents([
|
||||
{
|
||||
id: "1",
|
||||
title: "Event Conf.",
|
||||
start: new Date().toISOString().split("T")[0],
|
||||
extendedProps: { calendar: "Danger" },
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Meeting",
|
||||
start: new Date(Date.now() + 86400000).toISOString().split("T")[0],
|
||||
extendedProps: { calendar: "Success" },
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Workshop",
|
||||
start: new Date(Date.now() + 172800000).toISOString().split("T")[0],
|
||||
end: new Date(Date.now() + 259200000).toISOString().split("T")[0],
|
||||
extendedProps: { calendar: "Primary" },
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
||||
resetModalFields();
|
||||
setEventStartDate(selectInfo.startStr);
|
||||
setEventEndDate(selectInfo.endStr || selectInfo.startStr);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleEventClick = (clickInfo: EventClickArg) => {
|
||||
const event = clickInfo.event;
|
||||
setSelectedEvent(event as unknown as CalendarEvent);
|
||||
setEventTitle(event.title);
|
||||
setEventStartDate(event.start?.toISOString().split("T")[0] || "");
|
||||
setEventEndDate(event.end?.toISOString().split("T")[0] || "");
|
||||
setEventLevel(event.extendedProps.calendar);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleAddOrUpdateEvent = () => {
|
||||
if (selectedEvent) {
|
||||
// Update existing event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.map((event) =>
|
||||
event.id === selectedEvent.id
|
||||
? {
|
||||
...event,
|
||||
title: eventTitle,
|
||||
start: eventStartDate,
|
||||
end: eventEndDate,
|
||||
extendedProps: { calendar: eventLevel },
|
||||
}
|
||||
: event
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Add new event
|
||||
const newEvent: CalendarEvent = {
|
||||
id: Date.now().toString(),
|
||||
title: eventTitle,
|
||||
start: eventStartDate,
|
||||
end: eventEndDate,
|
||||
allDay: true,
|
||||
extendedProps: { calendar: eventLevel },
|
||||
};
|
||||
setEvents((prevEvents) => [...prevEvents, newEvent]);
|
||||
}
|
||||
closeModal();
|
||||
resetModalFields();
|
||||
};
|
||||
|
||||
const resetModalFields = () => {
|
||||
setEventTitle("");
|
||||
setEventStartDate("");
|
||||
setEventEndDate("");
|
||||
setEventLevel("");
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="React.js Calendar Dashboard | TailAdmin - Next.js Admin Dashboard Template"
|
||||
description="This is React.js Calendar Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
||||
<div className="custom-calendar">
|
||||
<FullCalendar
|
||||
ref={calendarRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={{
|
||||
left: "prev,next addEventButton",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}}
|
||||
events={events}
|
||||
selectable={true}
|
||||
select={handleDateSelect}
|
||||
eventClick={handleEventClick}
|
||||
eventContent={renderEventContent}
|
||||
customButtons={{
|
||||
addEventButton: {
|
||||
text: "Add Event +",
|
||||
click: openModal,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className="max-w-[700px] p-6 lg:p-10"
|
||||
>
|
||||
<div className="flex flex-col px-2 overflow-y-auto custom-scrollbar">
|
||||
<div>
|
||||
<h5 className="mb-2 font-semibold text-gray-800 modal-title text-theme-xl dark:text-white/90 lg:text-2xl">
|
||||
{selectedEvent ? "Edit Event" : "Add Event"}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Plan your next big moment: schedule or edit an event to stay on
|
||||
track
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Event Title
|
||||
</label>
|
||||
<InputField
|
||||
id="event-title"
|
||||
type="text"
|
||||
value={eventTitle}
|
||||
onChange={(e) => setEventTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<label className="block mb-4 text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Event Color
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-5">
|
||||
{Object.entries(calendarsEvents).map(([key, value]) => (
|
||||
<div key={key} className="n-chk">
|
||||
<div
|
||||
className={`form-check form-check-${value} form-check-inline`}
|
||||
>
|
||||
<label
|
||||
className="flex items-center text-sm text-gray-700 form-check-label dark:text-gray-400"
|
||||
htmlFor={`modal${key}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<input
|
||||
className="sr-only form-check-input"
|
||||
type="radio"
|
||||
name="event-level"
|
||||
value={key}
|
||||
id={`modal${key}`}
|
||||
checked={eventLevel === key}
|
||||
onChange={() => setEventLevel(key)}
|
||||
/>
|
||||
<span className="flex items-center justify-center w-5 h-5 mr-2 border border-gray-300 rounded-full box dark:border-gray-700">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full bg-white ${
|
||||
eventLevel === key ? "block" : "hidden"
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
{key}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Enter Start Date
|
||||
</label>
|
||||
<div className="relative">
|
||||
<InputField
|
||||
id="event-start-date"
|
||||
type="date"
|
||||
value={eventStartDate}
|
||||
onChange={(e) => setEventStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Enter End Date
|
||||
</label>
|
||||
<div className="relative">
|
||||
<InputField
|
||||
id="event-end-date"
|
||||
type="date"
|
||||
value={eventEndDate}
|
||||
onChange={(e) => setEventEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-6 modal-footer sm:justify-end">
|
||||
<Button
|
||||
onClick={closeModal}
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
size="md"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddOrUpdateEvent}
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
size="md"
|
||||
>
|
||||
{selectedEvent ? "Update Changes" : "Add Event"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventContent = (eventInfo: any) => {
|
||||
const colorClass = `fc-bg-${eventInfo.event.extendedProps.calendar.toLowerCase()}`;
|
||||
return (
|
||||
<div
|
||||
className={`event-fc-color flex fc-event-main ${colorClass} p-1 rounded-sm`}
|
||||
>
|
||||
<div className="fc-daygrid-event-dot"></div>
|
||||
<div className="fc-event-time">{eventInfo.timeText}</div>
|
||||
<div className="fc-event-title">{eventInfo.event.title}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
@@ -1,19 +0,0 @@
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import BarChartOne from "../../components/charts/bar/BarChartOne";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
|
||||
export default function BarChart() {
|
||||
return (
|
||||
<div>
|
||||
<PageMeta
|
||||
title="React.js Chart Dashboard | TailAdmin - React.js Admin Dashboard Template"
|
||||
description="This is React.js Chart Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<ComponentCard title="Bar Chart 1">
|
||||
<BarChartOne />
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import LineChartOne from "../../components/charts/line/LineChartOne";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
|
||||
export default function LineChart() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="React.js Chart Dashboard | TailAdmin - React.js Admin Dashboard Template"
|
||||
description="This is React.js Chart Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<ComponentCard title="Line Chart 1">
|
||||
<LineChartOne />
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import DefaultInputs from "../../components/form/form-elements/DefaultInputs";
|
||||
import InputGroup from "../../components/form/form-elements/InputGroup";
|
||||
import DropzoneComponent from "../../components/form/form-elements/DropZone";
|
||||
import CheckboxComponents from "../../components/form/form-elements/CheckboxComponents";
|
||||
import RadioButtons from "../../components/form/form-elements/RadioButtons";
|
||||
import ToggleSwitch from "../../components/form/form-elements/ToggleSwitch";
|
||||
import FileInputExample from "../../components/form/form-elements/FileInputExample";
|
||||
import SelectInputs from "../../components/form/form-elements/SelectInputs";
|
||||
import TextAreaInput from "../../components/form/form-elements/TextAreaInput";
|
||||
import InputStates from "../../components/form/form-elements/InputStates";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
|
||||
export default function FormElements() {
|
||||
return (
|
||||
<div>
|
||||
<PageMeta
|
||||
title="React.js Form Elements Dashboard | TailAdmin - React.js Admin Dashboard Template"
|
||||
description="This is React.js Form Elements Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<DefaultInputs />
|
||||
<SelectInputs />
|
||||
<TextAreaInput />
|
||||
<InputStates />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<InputGroup />
|
||||
<FileInputExample />
|
||||
<CheckboxComponents />
|
||||
<RadioButtons />
|
||||
<ToggleSwitch />
|
||||
<DropzoneComponent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,22 +12,23 @@ import { LinkResults } from '../../components/linker/LinkResults';
|
||||
import { PlugInIcon, CheckCircleIcon, FileIcon } from '../../icons';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function LinkerContentList() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { activeSector } = useSectorStore();
|
||||
const { pageSize } = usePageSizeStore();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
|
||||
const [content, setContent] = useState<ContentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState<number | null>(null);
|
||||
const [linkResults, setLinkResults] = useState<Record<number, any>>({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
startLoading('Loading content...');
|
||||
try {
|
||||
const data = await fetchContent({
|
||||
page: currentPage,
|
||||
@@ -40,9 +41,9 @@ export default function LinkerContentList() {
|
||||
console.error('Error loading content:', error);
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
}, [currentPage, pageSize, activeSector, toast]);
|
||||
}, [currentPage, pageSize, activeSector, toast, startLoading, stopLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -109,24 +110,18 @@ export default function LinkerContentList() {
|
||||
]} />}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading content...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cluster
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
@@ -233,7 +228,6 @@ export default function LinkerContentList() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module footer placeholder - module on hold */}
|
||||
</div>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { FileTextIcon, ArrowRightIcon, PlugInIcon, ArrowUpIcon } from '../../icons';
|
||||
import { fetchContent } from '../../services/api';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
|
||||
interface LinkerStats {
|
||||
totalLinked: number;
|
||||
totalLinks: number;
|
||||
averageLinksPerContent: number;
|
||||
contentWithLinks: number;
|
||||
contentWithoutLinks: number;
|
||||
}
|
||||
|
||||
export default function LinkerDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<LinkerStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [activeSite, activeSector]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch content to calculate stats
|
||||
const contentRes = await fetchContent({
|
||||
page_size: 1000,
|
||||
sector_id: activeSector?.id,
|
||||
});
|
||||
|
||||
const content = contentRes.results || [];
|
||||
|
||||
// Calculate stats
|
||||
const contentWithLinks = content.filter(c => c.internal_links && c.internal_links.length > 0);
|
||||
const totalLinks = content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0);
|
||||
const averageLinksPerContent = contentWithLinks.length > 0
|
||||
? (totalLinks / contentWithLinks.length)
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
totalLinked: contentWithLinks.length,
|
||||
totalLinks,
|
||||
averageLinksPerContent: parseFloat(averageLinksPerContent.toFixed(1)),
|
||||
contentWithLinks: contentWithLinks.length,
|
||||
contentWithoutLinks: content.length - contentWithLinks.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading linker stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Internal Linking Dashboard" description="Track your internal linking progress" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<PageHeader
|
||||
title="Internal Linking Dashboard"
|
||||
lastUpdated={new Date()}
|
||||
badge={{
|
||||
icon: <PlugInIcon />,
|
||||
color: 'blue',
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
to="/linker/content"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
<PlugInIcon />
|
||||
View Content
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Manage internal linking for your content
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading stats...</p>
|
||||
</div>
|
||||
) : stats ? (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<EnhancedMetricCard
|
||||
title="Total Linked"
|
||||
value={stats.totalLinked.toString()}
|
||||
subtitle={`${stats.contentWithoutLinks} without links`}
|
||||
icon={<FileTextIcon className="w-6 h-6" />}
|
||||
accentColor="blue"
|
||||
onClick={() => navigate('/linker/content')}
|
||||
/>
|
||||
|
||||
<EnhancedMetricCard
|
||||
title="Total Links"
|
||||
value={stats.totalLinks.toString()}
|
||||
subtitle="Internal links created"
|
||||
icon={<PlugInIcon className="w-6 h-6" />}
|
||||
accentColor="purple"
|
||||
onClick={() => navigate('/linker/content')}
|
||||
/>
|
||||
|
||||
<EnhancedMetricCard
|
||||
title="Avg Links/Content"
|
||||
value={stats.averageLinksPerContent.toString()}
|
||||
subtitle="Average per linked content"
|
||||
icon={<ArrowUpIcon className="w-6 h-6" />}
|
||||
accentColor="green"
|
||||
onClick={() => navigate('/linker/content')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/linker/content"
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PlugInIcon className="w-5 h-5 text-brand-500" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">Link Content</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Process content for internal linking</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-gray-400" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/content"
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileTextIcon className="w-5 h-5 text-purple-500" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">View Content</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Browse all content items</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-gray-400" />
|
||||
</Link>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { BoltIcon, FileTextIcon, ArrowUpIcon, ArrowRightIcon } from '../../icons';
|
||||
import { fetchContent } from '../../services/api';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
|
||||
interface OptimizerStats {
|
||||
totalOptimized: number;
|
||||
averageScoreImprovement: number;
|
||||
totalOperations: number;
|
||||
contentWithScores: number;
|
||||
contentWithoutScores: number;
|
||||
}
|
||||
|
||||
export default function OptimizerDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<OptimizerStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [activeSite, activeSector]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch content to calculate stats
|
||||
const contentRes = await fetchContent({
|
||||
page_size: 1000,
|
||||
sector_id: activeSector?.id,
|
||||
});
|
||||
|
||||
const content = contentRes.results || [];
|
||||
|
||||
// Calculate stats
|
||||
const contentWithScores = content.filter(
|
||||
c => c.optimization_scores && c.optimization_scores.overall_score
|
||||
);
|
||||
const totalOptimized = content.filter(c => c.optimizer_version > 0).length;
|
||||
|
||||
// Calculate average improvement (simplified - would need optimization tasks for real data)
|
||||
const averageScoreImprovement = contentWithScores.length > 0 ? 15.5 : 0;
|
||||
|
||||
setStats({
|
||||
totalOptimized,
|
||||
averageScoreImprovement: parseFloat(averageScoreImprovement.toFixed(1)),
|
||||
totalOperations: 0, // Would need to fetch from optimization tasks
|
||||
contentWithScores: contentWithScores.length,
|
||||
contentWithoutScores: content.length - contentWithScores.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading optimizer stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Optimization Dashboard" description="Track your content optimization progress" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<PageHeader
|
||||
title="Optimization Dashboard"
|
||||
lastUpdated={new Date()}
|
||||
badge={{
|
||||
icon: <BoltIcon />,
|
||||
color: 'orange',
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
to="/optimizer/content"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
<BoltIcon />
|
||||
Optimize Content
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Optimize your content for SEO, readability, and engagement
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading stats...</p>
|
||||
</div>
|
||||
) : stats ? (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<EnhancedMetricCard
|
||||
title="Total Optimized"
|
||||
value={stats.totalOptimized.toString()}
|
||||
subtitle={`${stats.contentWithoutScores} not optimized`}
|
||||
icon={<FileTextIcon className="w-6 h-6" />}
|
||||
accentColor="blue"
|
||||
onClick={() => navigate('/optimizer/content')}
|
||||
/>
|
||||
|
||||
<EnhancedMetricCard
|
||||
title="Avg Score Improvement"
|
||||
value={`+${stats.averageScoreImprovement}%`}
|
||||
subtitle="Average improvement per optimization"
|
||||
icon={<ArrowUpIcon className="w-6 h-6" />}
|
||||
accentColor="green"
|
||||
onClick={() => navigate('/optimizer/content')}
|
||||
/>
|
||||
|
||||
<EnhancedMetricCard
|
||||
title="Optimizations"
|
||||
value={stats.totalOperations.toString()}
|
||||
subtitle="Total optimization runs"
|
||||
icon={<BoltIcon className="w-6 h-6" />}
|
||||
accentColor="orange"
|
||||
onClick={() => navigate('/optimizer/content')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/optimizer/content"
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<BoltIcon className="w-5 h-5 text-warning-500" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">Optimize Content</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Select and optimize content items</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-gray-400" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/content"
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileTextIcon className="w-5 h-5 text-purple-500" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">View Content</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Browse all content items</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-gray-400" />
|
||||
</Link>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,18 +121,18 @@ export default function ClusterDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Cluster Details" description="Loading cluster information" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading cluster...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cluster) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Cluster Not Found" description="The requested cluster could not be found" />
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Cluster not found</p>
|
||||
@@ -140,12 +140,12 @@ export default function ClusterDetail() {
|
||||
Back to Clusters
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta
|
||||
title={`${cluster.name} - Cluster Details - IGNY8`}
|
||||
description={cluster.description || `View details for cluster: ${cluster.name}`}
|
||||
@@ -366,6 +366,6 @@ export default function ClusterDetail() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,778 +0,0 @@
|
||||
import { useEffect, useState, useMemo, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { ProgressBar } from "../../components/ui/progress";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
|
||||
import PageHeader from "../../components/common/PageHeader";
|
||||
|
||||
const Chart = lazy(() => import("react-apexcharts").then((mod) => ({ default: mod.default })));
|
||||
|
||||
import {
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
PieChartIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
TimeIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
PlugInIcon,
|
||||
ClockIcon,
|
||||
} from "../../icons";
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
// fetchSiteBlueprints,
|
||||
// SiteBlueprint,
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
|
||||
interface DashboardStats {
|
||||
keywords: {
|
||||
total: number;
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
byStatus: Record<string, number>;
|
||||
byCountry: Record<string, number>;
|
||||
};
|
||||
clusters: {
|
||||
total: number;
|
||||
withIdeas: number;
|
||||
withoutIdeas: number;
|
||||
totalVolume: number;
|
||||
avgKeywords: number;
|
||||
topClusters: Array<{ id: number; name: string; volume: number; keywords_count: number }>;
|
||||
};
|
||||
ideas: {
|
||||
total: number;
|
||||
queued: number;
|
||||
notQueued: number;
|
||||
byStatus: Record<string, number>;
|
||||
byContentType: Record<string, number>;
|
||||
};
|
||||
workflow: {
|
||||
keywordsReady: boolean;
|
||||
clustersBuilt: boolean;
|
||||
ideasGenerated: boolean;
|
||||
readyForWriter: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlannerDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
// Fetch real data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [keywordsRes, clustersRes, ideasRes, tasksRes] = await Promise.all([
|
||||
fetchKeywords({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchClusters({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchContentIdeas({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchTasks({ page_size: 1000, sector_id: activeSector?.id })
|
||||
]);
|
||||
|
||||
const keywords = keywordsRes.results || [];
|
||||
const mappedKeywords = keywords.filter(k => k.cluster && k.cluster.length > 0);
|
||||
const unmappedKeywords = keywords.filter(k => !k.cluster || k.cluster.length === 0);
|
||||
|
||||
const keywordsByStatus: Record<string, number> = {};
|
||||
const keywordsByCountry: Record<string, number> = {};
|
||||
keywords.forEach(k => {
|
||||
keywordsByStatus[k.status || 'unknown'] = (keywordsByStatus[k.status || 'unknown'] || 0) + 1;
|
||||
if (k.country) {
|
||||
keywordsByCountry[k.country] = (keywordsByCountry[k.country] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const clusters = clustersRes.results || [];
|
||||
const clustersWithIdeas = clusters.filter(c => c.keywords_count > 0);
|
||||
const totalVolume = clusters.reduce((sum, c) => sum + (c.volume || 0), 0);
|
||||
const totalKeywordsInClusters = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
|
||||
const avgKeywords = clusters.length > 0 ? Math.round(totalKeywordsInClusters / clusters.length) : 0;
|
||||
|
||||
const topClusters = [...clusters]
|
||||
.sort((a, b) => (b.volume || 0) - (a.volume || 0))
|
||||
.slice(0, 5)
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name || 'Unnamed Cluster',
|
||||
volume: c.volume || 0,
|
||||
keywords_count: c.keywords_count || 0
|
||||
}));
|
||||
|
||||
const ideas = ideasRes.results || [];
|
||||
const ideaIds = new Set(ideas.map(i => i.id));
|
||||
const tasks = tasksRes.results || [];
|
||||
const queuedIdeas = tasks.filter(t => t.idea && ideaIds.has(t.idea)).length;
|
||||
const notQueuedIdeas = ideas.length - queuedIdeas;
|
||||
|
||||
const ideasByStatus: Record<string, number> = {};
|
||||
const ideasByContentType: Record<string, number> = {};
|
||||
ideas.forEach(i => {
|
||||
ideasByStatus[i.status || 'new'] = (ideasByStatus[i.status || 'new'] || 0) + 1;
|
||||
if (i.content_type) {
|
||||
ideasByContentType[i.content_type] = (ideasByContentType[i.content_type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
setStats({
|
||||
keywords: {
|
||||
total: keywords.length,
|
||||
mapped: mappedKeywords.length,
|
||||
unmapped: unmappedKeywords.length,
|
||||
byStatus: keywordsByStatus,
|
||||
byCountry: keywordsByCountry
|
||||
},
|
||||
clusters: {
|
||||
total: clusters.length,
|
||||
withIdeas: clustersWithIdeas.length,
|
||||
withoutIdeas: clusters.length - clustersWithIdeas.length,
|
||||
totalVolume,
|
||||
avgKeywords,
|
||||
topClusters
|
||||
},
|
||||
ideas: {
|
||||
total: ideas.length,
|
||||
queued: queuedIdeas,
|
||||
notQueued: notQueuedIdeas,
|
||||
byStatus: ideasByStatus,
|
||||
byContentType: ideasByContentType
|
||||
},
|
||||
workflow: {
|
||||
keywordsReady: keywords.length > 0,
|
||||
clustersBuilt: clusters.length > 0,
|
||||
ideasGenerated: ideas.length > 0,
|
||||
readyForWriter: queuedIdeas > 0
|
||||
}
|
||||
});
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSector?.id, activeSite?.id]);
|
||||
|
||||
const keywordMappingPct = useMemo(() => {
|
||||
if (!stats || stats.keywords.total === 0) return 0;
|
||||
return Math.round((stats.keywords.mapped / stats.keywords.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const clustersIdeasPct = useMemo(() => {
|
||||
if (!stats || stats.clusters.total === 0) return 0;
|
||||
return Math.round((stats.clusters.withIdeas / stats.clusters.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const ideasQueuedPct = useMemo(() => {
|
||||
if (!stats || stats.ideas.total === 0) return 0;
|
||||
return Math.round((stats.ideas.queued / stats.ideas.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const plannerModules = [
|
||||
{
|
||||
title: "Keywords",
|
||||
description: "Manage and discover keywords",
|
||||
icon: ListIcon,
|
||||
color: "from-[var(--color-primary)] to-[var(--color-primary-dark)]",
|
||||
path: "/planner/keywords",
|
||||
count: stats?.keywords.total || 0,
|
||||
metric: `${stats?.keywords.mapped || 0} mapped`,
|
||||
},
|
||||
{
|
||||
title: "Clusters",
|
||||
description: "Keyword clusters and groups",
|
||||
icon: GroupIcon,
|
||||
color: "from-[var(--color-success)] to-[var(--color-success-dark)]",
|
||||
path: "/planner/clusters",
|
||||
count: stats?.clusters.total || 0,
|
||||
metric: `${stats?.clusters.totalVolume.toLocaleString() || 0} volume`,
|
||||
},
|
||||
{
|
||||
title: "Ideas",
|
||||
description: "Content ideas and concepts",
|
||||
icon: BoltIcon,
|
||||
color: "from-[var(--color-warning)] to-[var(--color-warning-dark)]",
|
||||
path: "/planner/ideas",
|
||||
count: stats?.ideas.total || 0,
|
||||
metric: `${stats?.ideas.queued || 0} queued`,
|
||||
},
|
||||
{
|
||||
title: "Keyword Opportunities",
|
||||
description: "Discover new keyword opportunities",
|
||||
icon: PieChartIcon,
|
||||
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
||||
path: "/planner/keyword-opportunities",
|
||||
count: 0,
|
||||
metric: "Discover new keywords",
|
||||
},
|
||||
];
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
id: 1,
|
||||
type: "Keywords Clustered",
|
||||
description: `${stats?.clusters.total || 0} new clusters created`,
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
icon: GroupIcon,
|
||||
color: "text-success-600",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Ideas Generated",
|
||||
description: `${stats?.ideas.total || 0} content ideas created`,
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
||||
icon: BoltIcon,
|
||||
color: "text-warning-600",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Keywords Added",
|
||||
description: `${stats?.keywords.total || 0} keywords in database`,
|
||||
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
icon: ListIcon,
|
||||
color: "text-brand-600",
|
||||
},
|
||||
];
|
||||
|
||||
const chartOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: "area",
|
||||
height: 300,
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
stroke: {
|
||||
curve: "smooth",
|
||||
width: 3,
|
||||
},
|
||||
xaxis: {
|
||||
categories: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
labels: { style: { colors: "var(--color-gray-500)" } },
|
||||
},
|
||||
yaxis: {
|
||||
labels: { style: { colors: "var(--color-gray-500)" } },
|
||||
},
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: { colors: "var(--color-gray-500)" },
|
||||
},
|
||||
colors: ["var(--color-primary)", "var(--color-success)", "var(--color-warning)"],
|
||||
grid: {
|
||||
borderColor: "var(--color-gray-200)",
|
||||
},
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
opacityFrom: 0.6,
|
||||
opacityTo: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chartSeries = [
|
||||
{
|
||||
name: "Keywords Added",
|
||||
data: [12, 19, 15, 25, 22, 18, 24],
|
||||
},
|
||||
{
|
||||
name: "Clusters Created",
|
||||
data: [8, 12, 10, 15, 14, 11, 16],
|
||||
},
|
||||
{
|
||||
name: "Ideas Generated",
|
||||
data: [5, 8, 6, 10, 9, 7, 11],
|
||||
},
|
||||
];
|
||||
|
||||
const keywordsStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
labels: Object.keys(stats.keywords.byStatus).filter(key => stats.keywords.byStatus[key] > 0),
|
||||
colors: ['var(--color-primary)', 'var(--color-success)', 'var(--color-warning)', 'var(--color-danger)', 'var(--color-purple)'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit',
|
||||
show: true
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '70%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: { show: false },
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-primary)',
|
||||
fontFamily: 'Outfit',
|
||||
formatter: () => {
|
||||
const total = Object.values(stats.keywords.byStatus).reduce((a, b) => a + b, 0);
|
||||
return total > 0 ? total.toString() : '0';
|
||||
}
|
||||
},
|
||||
total: { show: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = Object.keys(stats.keywords.byStatus)
|
||||
.filter(key => stats.keywords.byStatus[key] > 0)
|
||||
.map(key => stats.keywords.byStatus[key]);
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
const topClustersChart = useMemo(() => {
|
||||
if (!stats || stats.clusters.topClusters.length === 0) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 300
|
||||
},
|
||||
colors: ['var(--color-success)'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
borderRadius: 5,
|
||||
dataLabels: {
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => val.toLocaleString(),
|
||||
offsetX: 10
|
||||
},
|
||||
xaxis: {
|
||||
categories: stats.clusters.topClusters.map(c => c.name),
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} volume`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Search Volume',
|
||||
data: stats.clusters.topClusters.map(c => c.volume)
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const minutes = Math.floor((Date.now() - date.getTime()) / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-brand-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats && !loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{activeSector ? 'No data available for the selected sector.' : 'No data available. Select a sector or wait for data to load.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Planning Dashboard - IGNY8" description="Track your content planning progress" />
|
||||
<PageHeader
|
||||
title="Planning Dashboard"
|
||||
lastUpdated={lastUpdated}
|
||||
showRefresh={true}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<EnhancedMetricCard
|
||||
title="Total Keywords"
|
||||
value={stats.keywords.total}
|
||||
subtitle={`${stats.keywords.mapped} mapped • ${stats.keywords.unmapped} unmapped`}
|
||||
icon={<ListIcon className="size-6" />}
|
||||
accentColor="blue"
|
||||
trend={0}
|
||||
href="/planner/keywords"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Clusters Built"
|
||||
value={stats.clusters.total}
|
||||
subtitle={`${stats.clusters.totalVolume.toLocaleString()} volume • ${stats.clusters.avgKeywords} avg keywords`}
|
||||
icon={<GroupIcon className="size-6" />}
|
||||
accentColor="green"
|
||||
trend={0}
|
||||
href="/planner/clusters"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Ideas Generated"
|
||||
value={stats.ideas.total}
|
||||
subtitle={`${stats.ideas.queued} queued • ${stats.ideas.notQueued} not queued`}
|
||||
icon={<BoltIcon className="size-6" />}
|
||||
accentColor="orange"
|
||||
trend={0}
|
||||
href="/planner/ideas"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Mapping Progress"
|
||||
value={`${keywordMappingPct}%`}
|
||||
subtitle={`${stats.keywords.mapped} of ${stats.keywords.total} keywords mapped`}
|
||||
icon={<PieChartIcon className="size-6" />}
|
||||
accentColor="purple"
|
||||
trend={0}
|
||||
href="/planner/keywords"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Planner Modules */}
|
||||
<ComponentCard title="Planner Modules" desc="Access all planning tools and features">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{plannerModules.map((module) => {
|
||||
const Icon = module.icon;
|
||||
return (
|
||||
<Link
|
||||
key={module.title}
|
||||
to={module.path}
|
||||
className="rounded-2xl border-2 border-gray-200 bg-white p-6 hover:shadow-xl hover:-translate-y-1 transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`inline-flex size-14 rounded-xl bg-gradient-to-br ${module.color} items-center justify-center text-white shadow-lg`}>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{module.title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{module.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{module.count}</div>
|
||||
<div className="text-xs text-gray-500">{module.metric}</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] group-hover:translate-x-1 transition" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Activity Chart & Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ComponentCard title="Planning Activity" desc="Keywords, clusters, and ideas over the past week">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart options={chartOptions} series={chartSeries} type="area" height={300} />
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Recent Activity" desc="Latest planning actions and updates">
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => {
|
||||
const Icon = activity.icon;
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border border-gray-200 bg-white hover:shadow-md transition"
|
||||
>
|
||||
<div className={`size-10 rounded-lg bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center ${activity.color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-semibold text-gray-900">{activity.type}</h4>
|
||||
<span className="text-xs text-gray-500">{formatTimeAgo(activity.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{activity.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{keywordsStatusChart && (
|
||||
<ComponentCard title="Keywords by Status" desc="Distribution of keywords across statuses">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart
|
||||
options={keywordsStatusChart.options}
|
||||
series={keywordsStatusChart.series}
|
||||
type="donut"
|
||||
height={300}
|
||||
/>
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{topClustersChart && (
|
||||
<ComponentCard title="Top 5 Clusters by Volume" desc="Highest volume keyword clusters">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart
|
||||
options={topClustersChart.options}
|
||||
series={topClustersChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<ComponentCard title="Planning Progress" desc="Track your planning workflow progress">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Keyword Mapping</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{keywordMappingPct}%</span>
|
||||
</div>
|
||||
<ProgressBar value={keywordMappingPct} color="primary" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.keywords.mapped} of {stats.keywords.total} keywords mapped
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Clusters With Ideas</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{clustersIdeasPct}%</span>
|
||||
</div>
|
||||
<ProgressBar value={clustersIdeasPct} color="success" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.clusters.withIdeas} of {stats.clusters.total} clusters have ideas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Ideas Queued to Writer</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{ideasQueuedPct}%</span>
|
||||
</div>
|
||||
<ProgressBar value={ideasQueuedPct} color="warning" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.ideas.queued} of {stats.ideas.total} ideas queued
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" desc="Common planning tasks and shortcuts">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link
|
||||
to="/planner/keyword-opportunities"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-primary)] hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<ListIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Add Keywords</h4>
|
||||
<p className="text-sm text-gray-600">Discover opportunities</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/planner/clusters"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-success-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<GroupIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Auto Cluster</h4>
|
||||
<p className="text-sm text-gray-600">Group keywords</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-success-500 transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/planner/ideas"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-warning-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<BoltIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Generate Ideas</h4>
|
||||
<p className="text-sm text-gray-600">Create content ideas</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-warning-500 transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/automation"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-purple-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-purple)] to-[var(--color-purple-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<PlugInIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Setup Automation</h4>
|
||||
<p className="text-sm text-gray-600">Automate workflows</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-purple-500 transition" />
|
||||
</Link>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard title="How Planner Works" desc="Understanding the planning workflow">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<ListIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Keyword Discovery</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Discover high-volume keywords from our global database. Add keywords manually or import from keyword opportunities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<GroupIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">AI Clustering</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatically group related keywords into strategic clusters. Each cluster represents a content topic with shared search intent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Idea Generation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Generate content ideas from clusters using AI. Each idea includes title, outline, and target keywords for content creation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Getting Started" desc="Quick guide to using Planner">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-brand-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Add Keywords</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Start by adding keywords from the keyword opportunities page. You can search by volume, difficulty, or intent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-success-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Cluster Keywords</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Use the auto-cluster feature to group related keywords. Review and refine clusters to match your content strategy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-warning-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Generate Ideas</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create content ideas from your clusters. Queue ideas to the Writer module to start content creation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import PageHeader from "../../components/common/PageHeader";
|
||||
import { PieChartIcon } from "../../icons";
|
||||
|
||||
export default function Mapping() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Content Mapping - IGNY8" description="Keyword to content mapping" />
|
||||
<PageHeader
|
||||
title="Content Mapping"
|
||||
badge={{ icon: <PieChartIcon />, color: 'indigo' }}
|
||||
/>
|
||||
<ComponentCard title="Coming Soon" desc="Keyword to content mapping">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Content Mapping - Coming Soon
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Map keywords and clusters to existing pages and content
|
||||
</p>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,29 +382,29 @@ export default function ContentCalendar() {
|
||||
|
||||
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" />
|
||||
|
||||
<PageHeader
|
||||
@@ -803,6 +803,6 @@ export default function ContentCalendar() {
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Badge from '../../components/ui/badge/Badge';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { PieChartIcon } from '../../icons';
|
||||
import { Tooltip } from '../../components/ui/tooltip/Tooltip';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
interface IndustryWithData extends Industry {
|
||||
keywordsCount: number;
|
||||
@@ -26,8 +27,8 @@ const formatVolume = (volume: number): string => {
|
||||
|
||||
export default function Industries() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [industries, setIndustries] = useState<IndustryWithData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustries();
|
||||
@@ -35,7 +36,7 @@ export default function Industries() {
|
||||
|
||||
const loadIndustries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading industries...');
|
||||
const response = await fetchIndustries();
|
||||
const industriesList = response.industries || [];
|
||||
|
||||
@@ -86,7 +87,7 @@ export default function Industries() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load industries: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,25 +99,19 @@ export default function Industries() {
|
||||
badge={{ icon: <PieChartIcon />, color: 'blue' }}
|
||||
hideSiteSector={true}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Explore our comprehensive global database of industries, sectors, and high-volume keywords
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Explore our comprehensive global database of industries, sectors, and high-volume keywords
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading industries...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{industries.map((industry) => (
|
||||
<Card
|
||||
key={industry.slug}
|
||||
className="p-4 hover:shadow-lg transition-shadow duration-200 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{industries.map((industry) => (
|
||||
<Card
|
||||
key={industry.slug}
|
||||
className="p-4 hover:shadow-lg transition-shadow duration-200 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white leading-tight">
|
||||
{industry.name}
|
||||
@@ -183,8 +178,6 @@ export default function Industries() {
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,12 +4,13 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchSeedKeywords, SeedKeyword, fetchIndustries, Industry } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function SeedKeywords() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [keywords, setKeywords] = useState<SeedKeyword[]>([]);
|
||||
const [industries, setIndustries] = useState<Industry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
@@ -29,7 +30,7 @@ export default function SeedKeywords() {
|
||||
|
||||
const loadKeywords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading seed keywords...');
|
||||
const response = await fetchSeedKeywords({
|
||||
industry: selectedIndustry || undefined,
|
||||
search: searchTerm || undefined,
|
||||
@@ -38,12 +39,12 @@ export default function SeedKeywords() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load seed keywords: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Seed Keywords" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
|
||||
@@ -72,32 +73,27 @@ export default function SeedKeywords() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Keyword</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Industry</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Sector</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Volume</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Difficulty</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Country</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keywords.map((keyword) => (
|
||||
<tr key={keyword.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{keyword.keyword}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.industry_name}
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Keyword</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Industry</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Sector</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Volume</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Difficulty</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Country</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keywords.map((keyword) => (
|
||||
<tr key={keyword.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{keyword.keyword}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.industry_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.sector_name}
|
||||
@@ -118,7 +114,7 @@ export default function SeedKeywords() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
import ComponentCard from "../components/common/ComponentCard";
|
||||
|
||||
export default function Schedules() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Schedules - IGNY8" description="Automation schedules" />
|
||||
|
||||
<ComponentCard title="Coming Soon" desc="Automation schedules">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Schedules - Coming Soon
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Content scheduling and automation for consistent publishing
|
||||
</p>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function AccountSettings() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -15,34 +16,28 @@ export default function AccountSettings() {
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading account settings...');
|
||||
const response = await fetchAPI('/v1/system/settings/account/');
|
||||
setSettings(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load account settings: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Account Settings" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage your account preferences and profile</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Account settings management interface coming soon.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<Card className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Account settings management interface coming soon.</p>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,18 +66,18 @@ const CreditsAndBilling: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Usage & Billing" description="View your usage and billing" />
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading billing data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Usage & Billing" description="View your usage and billing" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -243,7 +243,7 @@ const CreditsAndBilling: React.FC = () => {
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { DownloadIcon, UploadIcon, DatabaseIcon, FileArchiveIcon, CheckCircleIcon } from '../../icons';
|
||||
|
||||
export default function ImportExport() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Import/Export - IGNY8" description="Data management" />
|
||||
|
||||
<Card className="p-8">
|
||||
<div className="text-center py-8 max-w-3xl mx-auto">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="p-4 bg-brand-100 dark:bg-brand-900/30 rounded-full">
|
||||
<DatabaseIcon className="w-12 h-12 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Coming Soon: Manage Your Data
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
Import and Export Your Content - Backup your keywords, articles, and settings. Move your content to other platforms. Download everything safely.
|
||||
</p>
|
||||
|
||||
<div className="bg-gradient-to-br from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 rounded-lg p-6 border border-brand-200 dark:border-brand-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
What will be available:
|
||||
</h2>
|
||||
<div className="space-y-3 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Export your keywords as a file</strong> (backup or share)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Export all your articles</strong> in different formats
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Import keywords from other sources</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Backup and restore</strong> your entire account
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Download your settings</strong> and configurations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import { fetchIndustries, Industry } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
|
||||
export default function Industries() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [industries, setIndustries] = useState<Industry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustries();
|
||||
@@ -16,49 +17,43 @@ export default function Industries() {
|
||||
|
||||
const loadIndustries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading industries...');
|
||||
const response = await fetchIndustries();
|
||||
setIndustries(response.industries || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load industries: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Industries" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Industries</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage global industry templates (Admin Only)</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{industries.map((industry) => (
|
||||
<Card key={industry.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{industry.name}</h3>
|
||||
<Badge variant="light" color={industry.is_active ? 'success' : 'dark'}>
|
||||
{industry.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
{industry.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{industry.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Sectors: {industry.sectors_count || 0}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{industries.map((industry) => (
|
||||
<Card key={industry.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{industry.name}</h3>
|
||||
<Badge variant="light" color={industry.is_active ? 'success' : 'dark'}>
|
||||
{industry.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
{industry.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{industry.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Sectors: {industry.sectors_count || 0}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { PricingPlan } from '../../components/ui/pricing-table';
|
||||
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
interface Plan {
|
||||
id: number;
|
||||
@@ -110,8 +111,8 @@ const getPlanDescription = (plan: Plan): string => {
|
||||
|
||||
export default function Plans() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlans();
|
||||
@@ -119,7 +120,7 @@ export default function Plans() {
|
||||
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading plans...');
|
||||
const response: PlanResponse = await fetchAPI('/v1/auth/plans/');
|
||||
// Filter only active plans and sort by price
|
||||
const activePlans = (response.results || [])
|
||||
@@ -133,7 +134,7 @@ export default function Plans() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load plans: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,7 +149,7 @@ export default function Plans() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Plans" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Plans</h1>
|
||||
@@ -157,11 +158,7 @@ export default function Plans() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading plans...</div>
|
||||
</div>
|
||||
) : pricingPlans.length === 0 ? (
|
||||
{pricingPlans.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">No active plans available</div>
|
||||
</div>
|
||||
@@ -183,6 +180,6 @@ export default function Plans() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
@@ -16,7 +17,7 @@ import { fetchAPI } from '../../services/api';
|
||||
|
||||
export default function Publishing() {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
|
||||
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
|
||||
@@ -31,7 +32,7 @@ export default function Publishing() {
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading publishing settings...');
|
||||
// TODO: Load from backend API when endpoint is available
|
||||
// For now, use defaults
|
||||
setDefaultDestinations(['sites']);
|
||||
@@ -43,7 +44,7 @@ export default function Publishing() {
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load settings: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,19 +74,8 @@ export default function Publishing() {
|
||||
{ value: 'shopify', label: 'Publish to Shopify (your Shopify store)' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Publishing Settings" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Publishing Settings - IGNY8" />
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -230,7 +220,7 @@ export default function Publishing() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { fetchAPI } from "../../services/api";
|
||||
|
||||
interface SystemStatus {
|
||||
timestamp: string;
|
||||
system: {
|
||||
cpu: { usage_percent: number; cores: number; status: string };
|
||||
memory: { total_gb: number; used_gb: number; available_gb: number; usage_percent: number; status: string };
|
||||
disk: { total_gb: number; used_gb: number; free_gb: number; usage_percent: number; status: string };
|
||||
};
|
||||
database: {
|
||||
connected: boolean;
|
||||
version: string;
|
||||
size: string;
|
||||
active_connections: number;
|
||||
status: string;
|
||||
};
|
||||
redis: {
|
||||
connected: boolean;
|
||||
status: string;
|
||||
};
|
||||
celery: {
|
||||
workers: string[];
|
||||
worker_count: number;
|
||||
tasks: { active: number; scheduled: number; reserved: number };
|
||||
status: string;
|
||||
};
|
||||
processes: {
|
||||
by_stack: {
|
||||
[key: string]: { count: number; cpu: number; memory_mb: number };
|
||||
};
|
||||
};
|
||||
modules: {
|
||||
planner: { keywords: number; clusters: number; content_ideas: number };
|
||||
writer: { tasks: number; images: number };
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'text-success-600 dark:text-success-400';
|
||||
case 'warning': return 'text-warning-600 dark:text-warning-400';
|
||||
case 'critical': return 'text-error-600 dark:text-error-400';
|
||||
default: return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400';
|
||||
case 'warning': return 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400';
|
||||
case 'critical': return 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400';
|
||||
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
export default function Status() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
||||
// So response IS the data object
|
||||
const response = await fetchAPI('/v1/system/status/');
|
||||
setStatus(response);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="System Status - IGNY8" description="System monitoring" />
|
||||
<ComponentCard title="System Status" desc="Loading system information...">
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-white mx-auto"></div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !status) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="System Status - IGNY8" description="System monitoring" />
|
||||
<ComponentCard title="System Status" desc="Error loading system information">
|
||||
<div className="text-center py-8 text-error-600 dark:text-error-400">
|
||||
{error || 'Failed to load system status'}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="System Status - IGNY8" description="System monitoring" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* System Resources */}
|
||||
<ComponentCard title="System Resources" desc="CPU, Memory, and Disk Usage">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* CPU */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">CPU</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.cpu?.status || 'unknown')}`}>
|
||||
{status.system?.cpu?.status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div
|
||||
className={`h-4 rounded-full ${
|
||||
(status.system?.cpu?.usage_percent || 0) < 80 ? 'bg-success-500' :
|
||||
(status.system?.cpu?.usage_percent || 0) < 95 ? 'bg-warning-500' : 'bg-error-500'
|
||||
}`}
|
||||
style={{ width: `${status.system?.cpu?.usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{status.system?.cpu?.usage_percent?.toFixed(1)}% used ({status.system?.cpu?.cores} cores)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Memory</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.memory?.status || 'unknown')}`}>
|
||||
{status.system?.memory?.status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div
|
||||
className={`h-4 rounded-full ${
|
||||
(status.system?.memory?.usage_percent || 0) < 80 ? 'bg-success-500' :
|
||||
(status.system?.memory?.usage_percent || 0) < 95 ? 'bg-warning-500' : 'bg-error-500'
|
||||
}`}
|
||||
style={{ width: `${status.system?.memory?.usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{status.system?.memory?.used_gb?.toFixed(1)} GB / {status.system?.memory?.total_gb?.toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Disk</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.disk?.status || 'unknown')}`}>
|
||||
{status.system?.disk?.status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div
|
||||
className={`h-4 rounded-full ${
|
||||
(status.system?.disk?.usage_percent || 0) < 80 ? 'bg-success-500' :
|
||||
(status.system?.disk?.usage_percent || 0) < 95 ? 'bg-warning-500' : 'bg-error-500'
|
||||
}`}
|
||||
style={{ width: `${status.system?.disk?.usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{status.system?.disk?.used_gb?.toFixed(1)} GB / {status.system?.disk?.total_gb?.toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Services Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Database */}
|
||||
<ComponentCard title="Database" desc="PostgreSQL Status">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Status</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.database?.status || 'unknown')}`}>
|
||||
{status.database?.connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{status.database?.version && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Version:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database.version.split(',')[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
{status.database?.size && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Size:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database.size}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Active Connections:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database?.active_connections || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Redis */}
|
||||
<ComponentCard title="Redis" desc="Cache & Message Broker">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Status</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.redis?.status || 'unknown')}`}>
|
||||
{status.redis?.connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Celery */}
|
||||
<ComponentCard title="Celery" desc="Task Queue Workers">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Workers</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.celery?.status || 'unknown')}`}>
|
||||
{status.celery?.worker_count || 0} active
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Active Tasks:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.active || 0}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Scheduled:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.scheduled || 0}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Reserved:</span>
|
||||
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.reserved || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Process Monitoring by Stack */}
|
||||
<ComponentCard title="Process Monitoring" desc="Resource usage by technology stack">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Stack</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Processes</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">CPU %</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Memory (MB)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{Object.entries(status.processes?.by_stack || {}).map(([stack, stats]) => (
|
||||
<tr key={stack}>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-800 dark:text-gray-200 capitalize">{stack}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.cpu.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.memory_mb.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Module Statistics */}
|
||||
<ComponentCard title="Module Statistics" desc="Data counts by module">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Planner Module */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Planner Module</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Keywords:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.keywords?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Clusters:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.clusters?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Content Ideas:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.content_ideas?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Writer Module */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Writer Module</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Tasks:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{status.modules?.writer?.tasks?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Images:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{status.modules?.writer?.images?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {new Date(status.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
@@ -15,8 +16,8 @@ interface Subscription {
|
||||
|
||||
export default function Subscriptions() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptions();
|
||||
@@ -24,13 +25,13 @@ export default function Subscriptions() {
|
||||
|
||||
const loadSubscriptions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading subscriptions...');
|
||||
const response = await fetchAPI('/v1/auth/subscriptions/');
|
||||
setSubscriptions(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load subscriptions: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,52 +49,46 @@ export default function Subscriptions() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Subscriptions" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Subscriptions</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage account subscriptions</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Account</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period Start</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period End</th>
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Account</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period Start</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<tr key={subscription.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{subscription.account_name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={getStatusColor(subscription.status) as any}>
|
||||
{subscription.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(subscription.current_period_start).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(subscription.current_period_end).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<tr key={subscription.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{subscription.account_name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={getStatusColor(subscription.status) as any}>
|
||||
{subscription.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(subscription.current_period_start).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(subscription.current_period_end).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
|
||||
export default function SystemSettings() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -15,34 +16,28 @@ export default function SystemSettings() {
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading system settings...');
|
||||
const response = await fetchAPI('/v1/system/settings/system/');
|
||||
setSettings(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load system settings: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="System Settings" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Global platform-wide settings</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">System settings management interface coming soon.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<Card className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">System settings management interface coming soon.</p>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
@@ -15,8 +16,8 @@ interface User {
|
||||
|
||||
export default function Users() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -24,61 +25,55 @@ export default function Users() {
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading users...');
|
||||
const response = await fetchAPI('/v1/auth/users/');
|
||||
setUsers(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load users: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Users" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Users</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage account users and permissions</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
<Card className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{user.email}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{user.username}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color="primary">{user.role}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={user.is_active ? 'success' : 'dark'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{user.email}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{user.username}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color="primary">{user.role}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color={user.is_active ? 'success' : 'dark'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import WorkflowGuide from '../../components/onboarding/WorkflowGuide';
|
||||
import {
|
||||
fetchSeedKeywords,
|
||||
@@ -34,6 +35,7 @@ import Label from '../../components/form/Label';
|
||||
|
||||
export default function IndustriesSectorsKeywords() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
||||
const { pageSize } = usePageSizeStore();
|
||||
@@ -42,7 +44,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
// Data state
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
// Track recently added keywords to preserve their state during reload
|
||||
@@ -82,14 +83,14 @@ export default function IndustriesSectorsKeywords() {
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading sites...');
|
||||
const response = await fetchSites();
|
||||
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||
setSites(activeSites);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load sites: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -640,24 +641,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
};
|
||||
}, [activeSector, handleAddToWorkflow]);
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Add Keywords" description="Browse and add keywords to your workflow" />
|
||||
<PageHeader
|
||||
title="Add Keywords"
|
||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show WorkflowGuide if no sites
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function SiteContentManager() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site Content Manager - IGNY8" />
|
||||
|
||||
<PageHeader
|
||||
@@ -309,7 +309,7 @@ export default function SiteContentManager() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -231,18 +231,18 @@ export default function SiteDashboard() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site Dashboard" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading site dashboard...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site Not Found" />
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Site not found</p>
|
||||
@@ -250,12 +250,12 @@ export default function SiteDashboard() {
|
||||
Back to Sites
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title={`${site.name} - Dashboard`} />
|
||||
<PageHeader
|
||||
title="Site Dashboard"
|
||||
@@ -384,7 +384,7 @@ export default function SiteDashboard() {
|
||||
No recent activity
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Site Editor - DEPRECATED
|
||||
*
|
||||
* Legacy SiteBlueprint page editor has been removed.
|
||||
* Use Writer module for content creation and editing.
|
||||
*/
|
||||
import React 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 { AlertIcon } from '../../icons';
|
||||
|
||||
export default function Editor() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageMeta
|
||||
title="Site Editor"
|
||||
description="Legacy site editor features"
|
||||
/>
|
||||
<PageHeader
|
||||
title="Site Editor"
|
||||
subtitle="This feature has been deprecated"
|
||||
backLink="../"
|
||||
/>
|
||||
|
||||
<Card className="p-8 text-center">
|
||||
<AlertIcon className="w-16 h-16 text-warning-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">Feature Deprecated</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
The SiteBlueprint page editor has been removed.
|
||||
Please use the Writer module to create and edit content.
|
||||
</p>
|
||||
<Button onClick={() => navigate('../')} variant="primary">
|
||||
Return to Sites
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -479,17 +479,17 @@ export default function SiteList() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site List" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading sites...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Your Websites - IGNY8" />
|
||||
<PageHeader
|
||||
title="Your Websites"
|
||||
@@ -693,6 +693,6 @@ export default function SiteList() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,17 +266,17 @@ export default function PageManager() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Page Manager" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading pages...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Page Manager - IGNY8" />
|
||||
|
||||
<PageHeader
|
||||
@@ -400,7 +400,7 @@ export default function PageManager() {
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -218,17 +218,17 @@ export default function PostEditor() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Post Editor" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading post...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title={content.id ? 'Edit Post' : 'New Post'} />
|
||||
|
||||
<div className="flex gap-6">
|
||||
@@ -677,7 +677,7 @@ export default function PostEditor() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* Publishing Queue Page
|
||||
* Shows scheduled content for publishing to external site
|
||||
* Allows reordering, pausing, and viewing calendar
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, 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 { fetchContent, Content } from '../../services/api';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ListIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TrashBinIcon,
|
||||
EyeIcon,
|
||||
} from '../../icons';
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
|
||||
interface QueueItem extends Content {
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
export default function PublishingQueue() {
|
||||
const { id: siteId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [queueItems, setQueueItems] = useState<QueueItem[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [draggedItem, setDraggedItem] = useState<QueueItem | null>(null);
|
||||
const [stats, setStats] = useState({
|
||||
scheduled: 0,
|
||||
publishing: 0,
|
||||
published: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch content that is scheduled or publishing
|
||||
const response = await fetchContent({
|
||||
site_id: Number(siteId),
|
||||
page_size: 100,
|
||||
});
|
||||
|
||||
const items = (response.results || []).filter(
|
||||
(c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing'
|
||||
);
|
||||
|
||||
// Sort by scheduled_publish_at
|
||||
items.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;
|
||||
});
|
||||
|
||||
setQueueItems(items);
|
||||
|
||||
// Calculate stats
|
||||
const allContent = response.results || [];
|
||||
setStats({
|
||||
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,
|
||||
failed: allContent.filter((c: Content) => c.site_status === 'failed').length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load queue: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadQueue();
|
||||
}
|
||||
}, [siteId, loadQueue]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, item: QueueItem) => {
|
||||
setDraggedItem(item);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetItem: QueueItem) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem || draggedItem.id === targetItem.id) return;
|
||||
|
||||
const newItems = [...queueItems];
|
||||
const draggedIndex = newItems.findIndex(item => item.id === draggedItem.id);
|
||||
const targetIndex = newItems.findIndex(item => item.id === targetItem.id);
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
setQueueItems(newItems);
|
||||
setDraggedItem(null);
|
||||
|
||||
// TODO: Call API to update scheduled_publish_at based on new order
|
||||
toast.success('Queue order updated');
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedItem(null);
|
||||
};
|
||||
|
||||
const handlePauseItem = (item: QueueItem) => {
|
||||
// Toggle pause state (in real implementation, this would call an API)
|
||||
setQueueItems(prev =>
|
||||
prev.map(i => i.id === item.id ? { ...i, isPaused: !i.isPaused } : i)
|
||||
);
|
||||
toast.info(item.isPaused ? 'Item resumed' : 'Item paused');
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = (item: QueueItem) => {
|
||||
// TODO: Call API to set site_status back to 'not_published'
|
||||
setQueueItems(prev => prev.filter(i => i.id !== item.id));
|
||||
toast.success('Removed from queue');
|
||||
};
|
||||
|
||||
const handleViewContent = (item: QueueItem) => {
|
||||
navigate(`/sites/${siteId}/posts/${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: QueueItem) => {
|
||||
if (item.isPaused) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<PauseIcon className="w-3 h-3" />
|
||||
Paused
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
const getItemsForDate = (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()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading queue...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||
<PageHeader
|
||||
title="Publishing Queue"
|
||||
badge={{ icon: <ClockIcon />, color: 'amber' }}
|
||||
breadcrumb="Sites / Publishing Queue"
|
||||
/>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-warning-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Scheduled</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ArrowRightIcon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.publishing}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Publishing</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.published}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Published</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-error-100 dark:bg-error-900/30 flex items-center justify-center">
|
||||
<TrashBinIcon className="w-5 h-5 text-error-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.failed}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{queueItems.length} items in queue
|
||||
</h2>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'list'}
|
||||
onClick={() => setViewMode('list')}
|
||||
startIcon={<ListIcon className="w-4 h-4" />}
|
||||
>
|
||||
List
|
||||
</ButtonGroupItem>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'calendar'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
startIcon={<CalendarIcon className="w-4 h-4" />}
|
||||
>
|
||||
Calendar
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{/* Queue Content */}
|
||||
{queueItems.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<ClockIcon 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 scheduled
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Content will appear here when it's scheduled for publishing.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||
Configure Publishing Settings
|
||||
</Button>
|
||||
</Card>
|
||||
) : viewMode === 'list' ? (
|
||||
/* List View */
|
||||
<ComponentCard title="Queue" desc="Drag items to reorder. Content publishes in order from top to bottom.">
|
||||
<div className="space-y-2">
|
||||
{queueItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(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'}
|
||||
${item.isPaused ? 'opacity-60' : ''}
|
||||
hover:border-brand-300 dark:hover:border-brand-700 transition-all cursor-move
|
||||
`}
|
||||
>
|
||||
{/* Order number */}
|
||||
<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>
|
||||
|
||||
{/* Content info */}
|
||||
<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>
|
||||
<span className="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span>{item.content_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
{getStatusBadge(item)}
|
||||
|
||||
{/* Actions */}
|
||||
<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={item.isPaused ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => handlePauseItem(item)}
|
||||
title={item.isPaused ? 'Resume' : 'Pause'}
|
||||
/>
|
||||
<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 */
|
||||
<ComponentCard title="Calendar View" desc="Content scheduled for the next 14 days">
|
||||
<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 dayItems = getItemsForDate(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
min-h-[100px] p-2 rounded-lg border
|
||||
${isToday
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${isToday ? 'text-brand-600' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayItems.slice(0, 3).map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleViewContent(item)}
|
||||
className="text-xs p-1 bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 rounded truncate cursor-pointer hover:bg-warning-200 dark:hover:bg-warning-900/50"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
))}
|
||||
{dayItems.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{dayItems.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}`)}>
|
||||
Back to Site
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||
Publishing Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -669,17 +669,17 @@ export default function SiteSettings() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site Settings" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading site settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Site Settings - IGNY8" />
|
||||
<PageHeader
|
||||
title="Site Settings"
|
||||
@@ -2003,7 +2003,7 @@ export default function SiteSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -118,18 +118,18 @@ export default function SyncDashboard() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Sync Dashboard" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading sync data...</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!syncStatus) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Sync Dashboard" />
|
||||
<Card className="p-6">
|
||||
<div className="text-center py-8">
|
||||
@@ -137,7 +137,7 @@ export default function SyncDashboard() {
|
||||
<p className="text-gray-600 dark:text-gray-400">No sync data available</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function SyncDashboard() {
|
||||
(mismatches?.posts.missing_in_igny8.length || 0);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Sync Dashboard" />
|
||||
|
||||
<PageHeader
|
||||
@@ -465,7 +465,7 @@ export default function SyncDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import BasicTableOne from "../../components/tables/BasicTables/BasicTableOne";
|
||||
|
||||
export default function BasicTables() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="React.js Basic Tables Dashboard | TailAdmin - Next.js Admin Dashboard Template"
|
||||
description="This is React.js Basic Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<ComponentCard title="Basic Table 1">
|
||||
<BasicTableOne />
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import { fetchAuthorProfiles, createAuthorProfile, updateAuthorProfile, deleteAuthorProfile, AuthorProfile } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
@@ -11,8 +12,8 @@ import { PlusIcon, UserIcon } from '../../icons';
|
||||
|
||||
export default function AuthorProfiles() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [profiles, setProfiles] = useState<AuthorProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<AuthorProfile | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -29,13 +30,13 @@ export default function AuthorProfiles() {
|
||||
|
||||
const loadProfiles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading author profiles...');
|
||||
const response = await fetchAuthorProfiles();
|
||||
setProfiles(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load author profiles: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +100,7 @@ export default function AuthorProfiles() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
<PageMeta title="Writing Styles" />
|
||||
<PageHeader
|
||||
title="Writing Styles"
|
||||
@@ -112,43 +113,37 @@ export default function AuthorProfiles() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{profiles.map((profile) => (
|
||||
<Card key={profile.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
|
||||
<Badge variant="light" color={profile.is_active ? 'success' : 'dark'}>
|
||||
{profile.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{profiles.map((profile) => (
|
||||
<Card key={profile.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
|
||||
<Badge variant="light" color={profile.is_active ? 'success' : 'dark'}>
|
||||
{profile.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{profile.description}</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Tone:</span>{' '}
|
||||
<span className="text-gray-900 dark:text-white">{profile.tone}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{profile.description}</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Tone:</span>{' '}
|
||||
<span className="text-gray-900 dark:text-white">{profile.tone}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Language:</span>{' '}
|
||||
<span className="text-gray-900 dark:text-white">{profile.language}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Language:</span>{' '}
|
||||
<span className="text-gray-900 dark:text-white">{profile.language}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleEdit(profile)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDelete(profile.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleEdit(profile)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDelete(profile.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -159,7 +154,7 @@ export default function AuthorProfiles() {
|
||||
data={formData}
|
||||
onChange={setFormData}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
import { useEffect, useState, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { ProgressBar } from "../../components/ui/progress";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
|
||||
import PageHeader from "../../components/common/PageHeader";
|
||||
|
||||
const Chart = lazy(() => import("react-apexcharts").then((mod) => ({ default: mod.default })));
|
||||
|
||||
import {
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
UserIcon,
|
||||
ShootingStarIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
ClockIcon,
|
||||
PieChartIcon,
|
||||
DocsIcon,
|
||||
} from "../../icons";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
|
||||
interface ThinkerStats {
|
||||
totalPrompts: number;
|
||||
activeProfiles: number;
|
||||
strategies: number;
|
||||
usageThisMonth: number;
|
||||
}
|
||||
|
||||
export default function ThinkerDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<ThinkerStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
// Mock data for now - will be replaced with real API calls
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setStats({
|
||||
totalPrompts: 24,
|
||||
activeProfiles: 8,
|
||||
strategies: 12,
|
||||
usageThisMonth: 342,
|
||||
});
|
||||
|
||||
setLastUpdated(new Date());
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [activeSite, activeSector]);
|
||||
|
||||
const thinkerModules = [
|
||||
{
|
||||
title: "Prompt Library",
|
||||
description: "Centralized prompt templates and AI instructions",
|
||||
icon: FileTextIcon,
|
||||
color: "from-[var(--color-warning)] to-[var(--color-warning-dark)]",
|
||||
path: "/thinker/prompts",
|
||||
count: stats?.totalPrompts || 0,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
title: "Author Profiles",
|
||||
description: "Voice templates and writing style guides",
|
||||
icon: UserIcon,
|
||||
color: "from-[var(--color-primary)] to-[var(--color-primary-dark)]",
|
||||
path: "/thinker/profiles",
|
||||
count: stats?.activeProfiles || 0,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
title: "Content Strategies",
|
||||
description: "Brand playbooks and content frameworks",
|
||||
icon: ShootingStarIcon,
|
||||
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
||||
path: "/thinker/strategies",
|
||||
count: stats?.strategies || 0,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
title: "Governance",
|
||||
description: "Track AI usage, compliance, and version control",
|
||||
icon: PieChartIcon,
|
||||
color: "from-[var(--color-success)] to-[var(--color-success-dark)]",
|
||||
path: "/thinker/governance",
|
||||
count: 0,
|
||||
status: "coming-soon",
|
||||
},
|
||||
];
|
||||
|
||||
const recentPrompts = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Long-form Article Template",
|
||||
category: "Content Generation",
|
||||
usage: 45,
|
||||
lastUsed: "2 hours ago",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "SEO-Optimized Brief",
|
||||
category: "Content Planning",
|
||||
usage: 32,
|
||||
lastUsed: "5 hours ago",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Brand Voice - Technical",
|
||||
category: "Author Profile",
|
||||
usage: 28,
|
||||
lastUsed: "1 day ago",
|
||||
},
|
||||
];
|
||||
|
||||
const chartOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: "donut",
|
||||
height: 300,
|
||||
},
|
||||
labels: ["Content Generation", "Content Planning", "Image Prompts", "Other"],
|
||||
colors: ["var(--color-warning)", "var(--color-primary)", "var(--color-purple)", "var(--color-success)"],
|
||||
legend: {
|
||||
position: "bottom",
|
||||
labels: { colors: "var(--color-gray-500)" },
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => `${val}%`,
|
||||
},
|
||||
};
|
||||
|
||||
const chartSeries = [35, 28, 22, 15];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Strategy Dashboard - IGNY8" description="Manage your content strategy" />
|
||||
<PageHeader title="Strategy Dashboard" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<EnhancedMetricCard
|
||||
title="Prompt Library"
|
||||
value={stats?.totalPrompts || 0}
|
||||
icon={<FileTextIcon className="size-6" />}
|
||||
trend={0}
|
||||
accentColor="orange"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Author Profiles"
|
||||
value={stats?.activeProfiles || 0}
|
||||
icon={<UserIcon className="size-6" />}
|
||||
trend={0}
|
||||
accentColor="blue"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Strategies"
|
||||
value={stats?.strategies || 0}
|
||||
icon={<ShootingStarIcon className="size-6" />}
|
||||
trend={0}
|
||||
accentColor="purple"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Usage This Month"
|
||||
value={stats?.usageThisMonth || 0}
|
||||
icon={<BoltIcon className="size-6" />}
|
||||
trend={0}
|
||||
accentColor="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thinker Modules */}
|
||||
<ComponentCard title="Thinker Modules" desc="Manage prompts, profiles, and strategies">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{thinkerModules.map((module) => {
|
||||
const Icon = module.icon;
|
||||
return (
|
||||
<Link
|
||||
key={module.title}
|
||||
to={module.path}
|
||||
className="rounded-2xl border-2 border-gray-200 bg-white p-6 hover:shadow-xl hover:-translate-y-1 transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`inline-flex size-14 rounded-xl bg-gradient-to-br ${module.color} items-center justify-center text-white shadow-lg`}>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
{module.status === "coming-soon" && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-600">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{module.title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{module.description}</p>
|
||||
{module.count > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold text-gray-900">{module.count}</span>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] group-hover:translate-x-1 transition" />
|
||||
</div>
|
||||
)}
|
||||
{module.status === "coming-soon" && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<span className="text-xs text-gray-500">Coming soon</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Recent Activity & Usage Chart */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ComponentCard title="Prompt Usage Distribution" desc="How your prompts are being used">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart options={chartOptions} series={chartSeries} type="donut" height={300} />
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Most Used Prompts" desc="Your top-performing prompt templates">
|
||||
<div className="space-y-4">
|
||||
{recentPrompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border border-gray-200 bg-white hover:shadow-md transition"
|
||||
>
|
||||
<div className="size-10 rounded-lg bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<FileTextIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-semibold text-gray-900">{prompt.name}</h4>
|
||||
<span className="text-xs font-semibold text-[var(--color-primary)]">{prompt.usage} uses</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600">
|
||||
<span>{prompt.category}</span>
|
||||
<span>•</span>
|
||||
<span>{prompt.lastUsed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" desc="Create new prompts, profiles, or strategies">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
onClick={() => navigate("/thinker/prompts")}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
startIcon={
|
||||
<div className="size-8 rounded-lg bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
className="!justify-start !h-auto !py-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">New Prompt</h4>
|
||||
<p className="text-sm text-gray-600">Create a reusable prompt template</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/thinker/profiles")}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
startIcon={
|
||||
<div className="size-8 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
className="!justify-start !h-auto !py-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">New Author Profile</h4>
|
||||
<p className="text-sm text-gray-600">Define a writing voice and style</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/thinker/strategies")}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
startIcon={
|
||||
<div className="size-8 rounded-lg bg-gradient-to-br from-[var(--color-purple)] to-[var(--color-purple-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
className="!justify-start !h-auto !py-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">New Strategy</h4>
|
||||
<p className="text-sm text-gray-600">Build a content playbook</p>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard title="How Thinker Works" desc="Understanding the strategic OS">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Centralized Control</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage all AI prompts, author voices, and brand guidelines in one place. Changes sync automatically to all content generation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Version Control</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Track changes to prompts and strategies with full version history. Roll back to previous versions when needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<ShootingStarIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Automated Enforcement</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Every piece of content automatically uses your defined prompts, author profiles, and brand guidelines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Getting Started" desc="Quick guide to using Thinker">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-brand-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Create Author Profiles</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Define writing voices and styles that match your brand. Each profile can have unique tone, structure, and guidelines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-success-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Build Prompt Library</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create reusable prompt templates for different content types. Use variables to make prompts dynamic and flexible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-purple-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Define Strategies</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create content playbooks that combine prompts, profiles, and guidelines. Apply strategies to specific clusters or content types.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import UserMetaCard from "../components/UserProfile/UserMetaCard";
|
||||
import UserInfoCard from "../components/UserProfile/UserInfoCard";
|
||||
import UserAddressCard from "../components/UserProfile/UserAddressCard";
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
|
||||
export default function UserProfiles() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="React.js Profile Dashboard | TailAdmin - Next.js Admin Dashboard Template"
|
||||
description="This is React.js Profile Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||
Profile
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<UserMetaCard />
|
||||
<UserInfoCard />
|
||||
<UserAddressCard />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,823 +0,0 @@
|
||||
import { useEffect, useState, useMemo, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { ProgressBar } from "../../components/ui/progress";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
|
||||
import PageHeader from "../../components/common/PageHeader";
|
||||
|
||||
const Chart = lazy(() => import("react-apexcharts").then((mod) => ({ default: mod.default })));
|
||||
|
||||
import {
|
||||
FileTextIcon,
|
||||
BoxIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PencilIcon,
|
||||
BoltIcon,
|
||||
ArrowRightIcon,
|
||||
PaperPlaneIcon,
|
||||
PlugInIcon,
|
||||
} from "../../icons";
|
||||
import {
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchTaxonomies,
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
|
||||
interface WriterStats {
|
||||
tasks: {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
avgWordCount: number;
|
||||
totalWordCount: number;
|
||||
};
|
||||
content: {
|
||||
total: number;
|
||||
drafts: number;
|
||||
published: number;
|
||||
publishedToSite: number;
|
||||
scheduledForPublish: number;
|
||||
totalWordCount: number;
|
||||
avgWordCount: number;
|
||||
byContentType: Record<string, number>;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
generated: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
byType: Record<string, number>;
|
||||
};
|
||||
workflow: {
|
||||
tasksCreated: boolean;
|
||||
contentGenerated: boolean;
|
||||
imagesGenerated: boolean;
|
||||
readyToPublish: boolean;
|
||||
};
|
||||
productivity: {
|
||||
contentThisWeek: number;
|
||||
contentThisMonth: number;
|
||||
avgGenerationTime: number;
|
||||
publishRate: number;
|
||||
};
|
||||
taxonomies: number;
|
||||
attributes: number;
|
||||
}
|
||||
|
||||
export default function WriterDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<WriterStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [tasksRes, contentRes, imagesRes, taxonomiesRes] = await Promise.all([
|
||||
fetchTasks({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchContent({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchContentImages({ sector_id: activeSector?.id }),
|
||||
fetchTaxonomies({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
]);
|
||||
|
||||
const tasks = tasksRes.results || [];
|
||||
const tasksByStatus: Record<string, number> = {};
|
||||
let pendingTasks = 0;
|
||||
let inProgressTasks = 0;
|
||||
let completedTasks = 0;
|
||||
let totalWordCount = 0;
|
||||
|
||||
tasks.forEach(t => {
|
||||
tasksByStatus[t.status || 'queued'] = (tasksByStatus[t.status || 'queued'] || 0) + 1;
|
||||
if (t.status === 'queued') pendingTasks++;
|
||||
else if (t.status === 'completed') completedTasks++;
|
||||
if (t.word_count) totalWordCount += t.word_count;
|
||||
});
|
||||
|
||||
const avgWordCount = tasks.length > 0 ? Math.round(totalWordCount / tasks.length) : 0;
|
||||
|
||||
const content = contentRes.results || [];
|
||||
let drafts = 0;
|
||||
let published = 0;
|
||||
let publishedToSite = 0;
|
||||
let scheduledForPublish = 0;
|
||||
let contentTotalWordCount = 0;
|
||||
const contentByType: Record<string, number> = {};
|
||||
|
||||
content.forEach(c => {
|
||||
if (c.status === 'draft') drafts++;
|
||||
else if (c.status === 'published') published++;
|
||||
// Count site_status for external publishing metrics
|
||||
if (c.site_status === 'published') publishedToSite++;
|
||||
else if (c.site_status === 'scheduled') scheduledForPublish++;
|
||||
if (c.word_count) contentTotalWordCount += c.word_count;
|
||||
});
|
||||
|
||||
const contentAvgWordCount = content.length > 0 ? Math.round(contentTotalWordCount / content.length) : 0;
|
||||
|
||||
const images = imagesRes.results || [];
|
||||
let generatedImages = 0;
|
||||
let pendingImages = 0;
|
||||
let failedImages = 0;
|
||||
const imagesByType: Record<string, number> = {};
|
||||
|
||||
images.forEach(imgGroup => {
|
||||
if (imgGroup.overall_status === 'complete') generatedImages++;
|
||||
else if (imgGroup.overall_status === 'pending' || imgGroup.overall_status === 'partial') pendingImages++;
|
||||
else if (imgGroup.overall_status === 'failed') failedImages++;
|
||||
|
||||
if (imgGroup.featured_image) {
|
||||
imagesByType['featured'] = (imagesByType['featured'] || 0) + 1;
|
||||
}
|
||||
if (imgGroup.in_article_images && imgGroup.in_article_images.length > 0) {
|
||||
imagesByType['in_article'] = (imagesByType['in_article'] || 0) + imgGroup.in_article_images.length;
|
||||
}
|
||||
});
|
||||
|
||||
const contentThisWeek = Math.floor(content.length * 0.3);
|
||||
const contentThisMonth = Math.floor(content.length * 0.7);
|
||||
const publishRate = content.length > 0 ? Math.round((published / content.length) * 100) : 0;
|
||||
|
||||
const taxonomies = taxonomiesRes.results || [];
|
||||
const taxonomyCount = taxonomies.length;
|
||||
// Note: Attributes are a subset of taxonomies with type 'product_attribute'
|
||||
const attributeCount = taxonomies.filter(t => t.taxonomy_type === 'product_attribute').length;
|
||||
|
||||
setStats({
|
||||
tasks: {
|
||||
total: tasks.length,
|
||||
byStatus: tasksByStatus,
|
||||
pending: pendingTasks,
|
||||
inProgress: inProgressTasks,
|
||||
completed: completedTasks,
|
||||
avgWordCount,
|
||||
totalWordCount
|
||||
},
|
||||
content: {
|
||||
total: content.length,
|
||||
drafts,
|
||||
published,
|
||||
publishedToSite,
|
||||
scheduledForPublish,
|
||||
totalWordCount: contentTotalWordCount,
|
||||
avgWordCount: contentAvgWordCount,
|
||||
byContentType: contentByType
|
||||
},
|
||||
images: {
|
||||
total: images.length,
|
||||
generated: generatedImages,
|
||||
pending: pendingImages,
|
||||
failed: failedImages,
|
||||
byType: imagesByType
|
||||
},
|
||||
workflow: {
|
||||
tasksCreated: tasks.length > 0,
|
||||
contentGenerated: content.length > 0,
|
||||
imagesGenerated: generatedImages > 0,
|
||||
readyToPublish: published > 0
|
||||
},
|
||||
productivity: {
|
||||
contentThisWeek,
|
||||
contentThisMonth,
|
||||
avgGenerationTime: 0,
|
||||
publishRate
|
||||
},
|
||||
taxonomies: taxonomyCount,
|
||||
attributes: attributeCount,
|
||||
});
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSector?.id, activeSite?.id]);
|
||||
|
||||
const completionRate = useMemo(() => {
|
||||
if (!stats || stats.tasks.total === 0) return 0;
|
||||
return Math.round((stats.tasks.completed / stats.tasks.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const writerModules = [
|
||||
{
|
||||
title: "Tasks",
|
||||
description: "Content writing tasks and assignments",
|
||||
icon: FileTextIcon,
|
||||
color: "from-[var(--color-primary)] to-[var(--color-primary-dark)]",
|
||||
path: "/writer/tasks",
|
||||
count: stats?.tasks.total || 0,
|
||||
metric: `${stats?.tasks.completed || 0} completed`,
|
||||
},
|
||||
{
|
||||
title: "Content",
|
||||
description: "Generated content and drafts",
|
||||
icon: PencilIcon,
|
||||
color: "from-[var(--color-success)] to-[var(--color-success-dark)]",
|
||||
path: "/writer/content",
|
||||
count: stats?.content.total || 0,
|
||||
metric: `${stats?.content.published || 0} published`,
|
||||
},
|
||||
{
|
||||
title: "Images",
|
||||
description: "Generated images and assets",
|
||||
icon: BoxIcon,
|
||||
color: "from-[var(--color-warning)] to-[var(--color-warning-dark)]",
|
||||
path: "/writer/images",
|
||||
count: stats?.images.generated || 0,
|
||||
metric: `${stats?.images.pending || 0} pending`,
|
||||
},
|
||||
{
|
||||
title: "Published to Site",
|
||||
description: "Content published to external site",
|
||||
icon: PaperPlaneIcon,
|
||||
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
||||
path: "/writer/published",
|
||||
count: stats?.content.publishedToSite || 0,
|
||||
metric: stats?.content.scheduledForPublish ? `${stats.content.scheduledForPublish} scheduled` : "None scheduled",
|
||||
},
|
||||
{
|
||||
title: "Taxonomies",
|
||||
description: "Manage content taxonomies",
|
||||
icon: BoltIcon,
|
||||
color: "from-[var(--color-info)] to-[var(--color-info-dark)]",
|
||||
path: "/writer/taxonomies",
|
||||
count: stats?.taxonomies || 0,
|
||||
metric: `${stats?.taxonomies || 0} total`,
|
||||
},
|
||||
{
|
||||
title: "Attributes",
|
||||
description: "Manage content attributes",
|
||||
icon: PlugInIcon,
|
||||
color: "from-[var(--color-secondary)] to-[var(--color-secondary-dark)]",
|
||||
path: "/writer/attributes",
|
||||
count: stats?.attributes || 0,
|
||||
metric: `${stats?.attributes || 0} total`,
|
||||
},
|
||||
];
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
id: 1,
|
||||
type: "Content Published",
|
||||
description: `${stats?.content.published || 0} pieces published to site`,
|
||||
timestamp: new Date(Date.now() - 30 * 60 * 1000),
|
||||
icon: PaperPlaneIcon,
|
||||
color: "text-success-600",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Content Generated",
|
||||
description: `${stats?.content.total || 0} content pieces created`,
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
icon: PencilIcon,
|
||||
color: "text-brand-600",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Images Generated",
|
||||
description: `${stats?.images.generated || 0} images created`,
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
||||
icon: BoxIcon,
|
||||
color: "text-warning-600",
|
||||
},
|
||||
];
|
||||
|
||||
const chartOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: "area",
|
||||
height: 300,
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
stroke: {
|
||||
curve: "smooth",
|
||||
width: 3,
|
||||
},
|
||||
xaxis: {
|
||||
categories: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
labels: { style: { colors: "var(--color-gray-500)" } },
|
||||
},
|
||||
yaxis: {
|
||||
labels: { style: { colors: "var(--color-gray-500)" } },
|
||||
},
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: { colors: "var(--color-gray-500)" },
|
||||
},
|
||||
colors: ["var(--color-primary)", "var(--color-success)", "var(--color-warning)"],
|
||||
grid: {
|
||||
borderColor: "var(--color-gray-200)",
|
||||
},
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
opacityFrom: 0.6,
|
||||
opacityTo: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chartSeries = [
|
||||
{
|
||||
name: "Content Created",
|
||||
data: [12, 19, 15, 25, 22, 18, 24],
|
||||
},
|
||||
{
|
||||
name: "Tasks Completed",
|
||||
data: [8, 12, 10, 15, 14, 11, 16],
|
||||
},
|
||||
{
|
||||
name: "Images Generated",
|
||||
data: [5, 8, 6, 10, 9, 7, 11],
|
||||
},
|
||||
];
|
||||
|
||||
const tasksStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
labels: Object.keys(stats.tasks.byStatus).filter(key => stats.tasks.byStatus[key] > 0),
|
||||
colors: ['var(--color-primary)', 'var(--color-warning)', 'var(--color-success)', 'var(--color-danger)', 'var(--color-purple)'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit',
|
||||
show: true
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '70%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: { show: false },
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-primary)',
|
||||
fontFamily: 'Outfit',
|
||||
formatter: () => {
|
||||
const total = Object.values(stats.tasks.byStatus).reduce((a: number, b: number) => a + b, 0);
|
||||
return total > 0 ? total.toString() : '0';
|
||||
}
|
||||
},
|
||||
total: { show: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = Object.keys(stats.tasks.byStatus)
|
||||
.filter(key => stats.tasks.byStatus[key] > 0)
|
||||
.map(key => stats.tasks.byStatus[key]);
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
const contentStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 300
|
||||
},
|
||||
colors: ['var(--color-primary)', 'var(--color-warning)', 'var(--color-success)'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '55%',
|
||||
borderRadius: 5
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true
|
||||
},
|
||||
xaxis: {
|
||||
categories: ['Drafts', 'Published'],
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
strokeDashArray: 4
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Content',
|
||||
data: [stats.content.drafts, stats.content.published]
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const minutes = Math.floor((Date.now() - date.getTime()) / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-brand-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats && !loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{activeSector ? 'No data available for the selected sector.' : 'No data available. Select a sector or wait for data to load.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Content Creation Dashboard - IGNY8" description="Track your writing progress and productivity" />
|
||||
<PageHeader
|
||||
title="Content Creation Dashboard"
|
||||
lastUpdated={lastUpdated}
|
||||
showRefresh={true}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<EnhancedMetricCard
|
||||
title="Total Tasks"
|
||||
value={stats.tasks.total}
|
||||
subtitle={`${stats.tasks.completed} completed • ${stats.tasks.pending} pending`}
|
||||
icon={<FileTextIcon className="size-6" />}
|
||||
accentColor="blue"
|
||||
trend={0}
|
||||
href="/writer/tasks"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Content Pieces"
|
||||
value={stats.content.total}
|
||||
subtitle={`${stats.content.published} published • ${stats.content.drafts} drafts`}
|
||||
icon={<PencilIcon className="size-6" />}
|
||||
accentColor="green"
|
||||
trend={0}
|
||||
href="/writer/content"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Images Generated"
|
||||
value={stats.images.generated}
|
||||
subtitle={`${stats.images.total} total • ${stats.images.pending} pending`}
|
||||
icon={<BoxIcon className="size-6" />}
|
||||
accentColor="orange"
|
||||
trend={0}
|
||||
href="/writer/images"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Publish Rate"
|
||||
value={`${stats.productivity.publishRate}%`}
|
||||
subtitle={`${stats.content.published} of ${stats.content.total} published`}
|
||||
icon={<PaperPlaneIcon className="size-6" />}
|
||||
accentColor="purple"
|
||||
trend={0}
|
||||
href="/writer/published"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Writer Modules */}
|
||||
<ComponentCard title="Writer Modules" desc="Access all content creation tools and features">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{writerModules.map((module) => {
|
||||
const Icon = module.icon;
|
||||
return (
|
||||
<Link
|
||||
key={module.title}
|
||||
to={module.path}
|
||||
className="rounded-2xl border-2 border-gray-200 bg-white p-6 hover:shadow-xl hover:-translate-y-1 transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`inline-flex size-14 rounded-xl bg-gradient-to-br ${module.color} items-center justify-center text-white shadow-lg`}>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{module.title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{module.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{module.count}</div>
|
||||
<div className="text-xs text-gray-500">{module.metric}</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] group-hover:translate-x-1 transition" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Activity Chart & Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ComponentCard title="Content Creation Activity" desc="Tasks, content, and images over the past week">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart options={chartOptions} series={chartSeries} type="area" height={300} />
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Recent Activity" desc="Latest content creation actions and updates">
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => {
|
||||
const Icon = activity.icon;
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border border-gray-200 bg-white hover:shadow-md transition"
|
||||
>
|
||||
<div className={`size-10 rounded-lg bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center ${activity.color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-semibold text-gray-900">{activity.type}</h4>
|
||||
<span className="text-xs text-gray-500">{formatTimeAgo(activity.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{activity.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{tasksStatusChart && (
|
||||
<ComponentCard title="Tasks by Status" desc="Task distribution across statuses">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart
|
||||
options={tasksStatusChart.options}
|
||||
series={tasksStatusChart.series}
|
||||
type="donut"
|
||||
height={300}
|
||||
/>
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{contentStatusChart && (
|
||||
<ComponentCard title="Content by Status" desc="Distribution across workflow stages">
|
||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
||||
<Chart
|
||||
options={contentStatusChart.options}
|
||||
series={contentStatusChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</Suspense>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Productivity Metrics */}
|
||||
<ComponentCard title="Productivity Metrics" desc="Content creation performance tracking">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Task Completion</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{completionRate}%</span>
|
||||
</div>
|
||||
<ProgressBar value={completionRate} color="primary" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.tasks.completed} of {stats.tasks.total} tasks completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Publish Rate</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{stats.productivity.publishRate}%</span>
|
||||
</div>
|
||||
<ProgressBar value={stats.productivity.publishRate} color="success" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.content.published} of {stats.content.total} content published
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Image Generation</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">
|
||||
{stats.images.total > 0 ? Math.round((stats.images.generated / stats.images.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={stats.images.total > 0 ? Math.round((stats.images.generated / stats.images.total) * 100) : 0}
|
||||
color="warning"
|
||||
size="md"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.images.generated} of {stats.images.total} images generated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" desc="Common content creation tasks and shortcuts">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link
|
||||
to="/writer/tasks"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-primary)] hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<FileTextIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Create Task</h4>
|
||||
<p className="text-sm text-gray-600">New writing task</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/content"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-success-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<PencilIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Generate Content</h4>
|
||||
<p className="text-sm text-gray-600">AI content creation</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-success-500 transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/images"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-warning-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<BoxIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Generate Images</h4>
|
||||
<p className="text-sm text-gray-600">Create visuals</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-warning-500 transition" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/published"
|
||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-purple-500 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-purple)] to-[var(--color-purple-dark)] flex items-center justify-center text-white shadow-lg">
|
||||
<PaperPlaneIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Publish Content</h4>
|
||||
<p className="text-sm text-gray-600">Publish to Site</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-purple-500 transition" />
|
||||
</Link>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard title="How Writer Works" desc="Understanding the content creation workflow">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<FileTextIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Task Creation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create writing tasks from content ideas. Each task includes target keywords, outline, and word count requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">AI Content Generation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Generate full content pieces using AI. Content is created based on your prompts, author profiles, and brand guidelines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 size-10 rounded-lg bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-md">
|
||||
<BoxIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Image Generation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatically generate featured images and in-article images for your content. Images are optimized for SEO and engagement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Getting Started" desc="Quick guide to using Writer">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-brand-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Create Tasks</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Start by creating writing tasks from content ideas in the Planner module. Tasks define what content needs to be written.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-success-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Generate Content</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Use AI to generate content from tasks. Review and edit generated content before publishing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 size-8 rounded-full bg-warning-500 text-white flex items-center justify-center font-bold text-sm">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Publish</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Once content is reviewed and images are generated, publish directly to WordPress or export for manual publishing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Drafts Page - Filtered Tasks with status='draft'
|
||||
* Consistent with Keywords page layout, structure and design
|
||||
*/
|
||||
|
||||
import Tasks from './Tasks';
|
||||
|
||||
export default function Drafts() {
|
||||
// Drafts is just Tasks with status='draft' filter applied
|
||||
// For now, we'll use the Tasks component but could enhance it later
|
||||
// to show only draft status tasks by default
|
||||
return <Tasks />;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import ComponentCard from '../../components/common/ComponentCard';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import {
|
||||
getAccountSettings,
|
||||
updateAccountSettings,
|
||||
@@ -46,9 +47,9 @@ 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 [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
@@ -122,7 +123,7 @@ export default function AccountSettingsPage() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading settings...');
|
||||
const accountData = await getAccountSettings();
|
||||
setSettings(accountData);
|
||||
setAccountForm({
|
||||
@@ -139,7 +140,7 @@ export default function AccountSettingsPage() {
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,26 +270,6 @@ export default function AccountSettingsPage() {
|
||||
{ id: 'team' as TabType, label: 'Team', icon: <UsersIcon className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
||||
<PageHeader
|
||||
title="Account Settings"
|
||||
badge={{ icon: <SettingsIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-[var(--color-brand-500)]" />
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Page titles based on active tab
|
||||
const pageTitles = {
|
||||
account: { title: 'Account Information', description: 'Manage your organization and billing information' },
|
||||
@@ -305,18 +286,17 @@ export default function AccountSettingsPage() {
|
||||
badge={{ icon: <SettingsIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
parent="Account Settings"
|
||||
/>
|
||||
<div className="p-6">
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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 && (
|
||||
{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>
|
||||
|
||||
@@ -19,6 +19,7 @@ import Select from '../../components/form/Select';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useNotificationStore } from '../../store/notificationStore';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import type { NotificationAPI } from '../../services/notifications.api';
|
||||
import { deleteNotification as deleteNotificationAPI } from '../../services/notifications.api';
|
||||
|
||||
@@ -30,7 +31,7 @@ interface FilterState {
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [apiNotifications, setApiNotifications] = useState<NotificationAPI[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
|
||||
const {
|
||||
unreadCount,
|
||||
@@ -53,7 +54,7 @@ export default function NotificationsPage() {
|
||||
}, []);
|
||||
|
||||
const loadNotifications = async () => {
|
||||
setLoading(true);
|
||||
startLoading('Loading notifications...');
|
||||
try {
|
||||
// Import here to avoid circular dependencies
|
||||
const { fetchNotifications } = await import('../../services/notifications.api');
|
||||
@@ -63,7 +64,7 @@ export default function NotificationsPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,11 +314,7 @@ export default function NotificationsPage() {
|
||||
|
||||
{/* Notifications List */}
|
||||
<Card className="overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : filteredNotifications.length === 0 ? (
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="text-center p-12">
|
||||
<BellIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
|
||||
@@ -24,6 +24,7 @@ import CreditCostBreakdownPanel from '../../components/billing/CreditCostBreakdo
|
||||
// import CreditCostsPanel from '../../components/billing/CreditCostsPanel'; // Hidden from regular users
|
||||
// import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel'; // Moved to UsageAnalyticsPage
|
||||
import { convertToPricingPlan } from '../../utils/pricingHelpers';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import {
|
||||
getCreditBalance,
|
||||
getCreditPackages,
|
||||
@@ -63,7 +64,7 @@ export default function PlansAndBillingPage() {
|
||||
const location = useLocation();
|
||||
// Derive active tab from URL path
|
||||
const activeTab = getTabFromPath(location.pathname);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [planLoadingId, setPlanLoadingId] = useState<number | null>(null);
|
||||
const [purchaseLoadingId, setPurchaseLoadingId] = useState<number | null>(null);
|
||||
@@ -109,7 +110,7 @@ export default function PlansAndBillingPage() {
|
||||
|
||||
const loadData = async (allowRetry = true) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
startLoading('Loading billing data...');
|
||||
// Fetch in controlled sequence to avoid burst 429s on auth/system scopes
|
||||
const balanceData = await getCreditBalance();
|
||||
|
||||
@@ -213,7 +214,7 @@ export default function PlansAndBillingPage() {
|
||||
console.error('Billing load error:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,7 +268,6 @@ export default function PlansAndBillingPage() {
|
||||
handleBillingError(err, 'Failed to purchase credits');
|
||||
} finally {
|
||||
setPurchaseLoadingId(null);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,23 +340,6 @@ export default function PlansAndBillingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Plans & Billing" description="Manage your subscription and billing" />
|
||||
<PageHeader
|
||||
title="Plans & Billing"
|
||||
badge={{ icon: <CreditCardIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2Icon className="w-8 h-8 animate-spin text-[var(--color-brand-500)]" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const currentSubscription = subscriptions.find((sub) => sub.status === 'active') || subscriptions[0];
|
||||
const currentPlanId = typeof currentSubscription?.plan === 'object' ? currentSubscription.plan.id : currentSubscription?.plan;
|
||||
// Fallback to account plan if subscription is missing
|
||||
@@ -384,7 +367,7 @@ export default function PlansAndBillingPage() {
|
||||
badge={{ icon: <CreditCardIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
parent="Plans & Billing"
|
||||
/>
|
||||
<div className="p-6">
|
||||
|
||||
{/* Activation / pending payment notice */}
|
||||
{!hasActivePlan && (
|
||||
<div className="mb-4 p-4 rounded-lg border border-warning-200 bg-warning-50 text-warning-800 dark:border-warning-800 dark:bg-warning-900/20 dark:text-warning-200">
|
||||
@@ -893,7 +876,6 @@ export default function PlansAndBillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +97,6 @@ export default function UsageAnalyticsPage() {
|
||||
badge={{ icon: <TrendingUpIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
parent="Usage & Analytics"
|
||||
/>
|
||||
<div className="p-6">
|
||||
{/* Quick Stats Overview */}
|
||||
{creditBalance && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* User Profile Settings Page
|
||||
* Manage personal profile settings
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SaveIcon, UserIcon, MailIcon, LockIcon, Loader2Icon } from '../../icons';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import InputField from '../../components/form/input/InputField';
|
||||
import Select from '../../components/form/Select';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
|
||||
export default function ProfileSettingsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [profile, setProfile] = useState({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '+1 234 567 8900',
|
||||
timezone: 'America/New_York',
|
||||
language: 'en',
|
||||
emailNotifications: true,
|
||||
marketingEmails: false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<UserIcon className="w-6 h-6" />
|
||||
Your Profile
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Update your personal settings - Your name, preferences, and notification choices
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
>
|
||||
{saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||
{saving ? 'Saving...' : '✓ Save My Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">About You</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
label="First Name"
|
||||
value={profile.firstName}
|
||||
onChange={(e) => setProfile({ ...profile, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="text"
|
||||
label="Last Name"
|
||||
value={profile.lastName}
|
||||
onChange={(e) => setProfile({ ...profile, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="email"
|
||||
label="Email"
|
||||
value={profile.email}
|
||||
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputField
|
||||
type="tel"
|
||||
label="Phone Number (optional)"
|
||||
value={profile.phone}
|
||||
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">How You Like It</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Select
|
||||
label="Your Timezone"
|
||||
options={[
|
||||
{ value: 'America/New_York', label: 'Eastern Time' },
|
||||
{ value: 'America/Chicago', label: 'Central Time' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
]}
|
||||
defaultValue={profile.timezone}
|
||||
onChange={(val) => setProfile({ ...profile, timezone: val })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Language"
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
]}
|
||||
defaultValue={profile.language}
|
||||
onChange={(val) => setProfile({ ...profile, language: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">What You Want to Hear About</h2>
|
||||
<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">Important Updates</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Get notified about important changes to your account
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={profile.emailNotifications}
|
||||
onChange={(checked) => setProfile({ ...profile, emailNotifications: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Tips & Product Updates (optional)</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Hear about new features and content tips
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={profile.marketingEmails}
|
||||
onChange={(checked) => setProfile({ ...profile, marketingEmails: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<LockIcon className="w-5 h-5" />
|
||||
Security
|
||||
</h2>
|
||||
<Button variant="outline" tone="neutral">
|
||||
Change Password
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
new-updated.md
165
new-updated.md
@@ -1,165 +0,0 @@
|
||||
## IGNY8 AI & Configuration Settings Report
|
||||
|
||||
---
|
||||
|
||||
### 1. AI Mode Configuration
|
||||
|
||||
**Architecture**: Two-tier database-driven model configuration system
|
||||
|
||||
#### AIModelConfig Model (Primary)
|
||||
Stores all AI model configurations in database, replacing legacy hardcoded constants.
|
||||
|
||||
**Model Types**:
|
||||
- Text Generation ✅
|
||||
- Image Generation ✅
|
||||
|
||||
|
||||
**Supported Providers**:
|
||||
- OpenAI ✅
|
||||
- Runware ✅
|
||||
|
||||
**Key Configuration Fields**:
|
||||
|
||||
| Category | Fields |
|
||||
|----------|--------|
|
||||
| **Identity** | `model_id`, `display_name`, `model_type`, `provider` |
|
||||
| **Text Pricing** | `input_token_rate`, `output_token_rate` (per 1M tokens in USD) |
|
||||
| **Text Limits** | `max_input_tokens`, `max_output_tokens` |
|
||||
| **Image Pricing** | `cost_per_image` (fixed USD per image) |
|
||||
| **Image Config** | `available_sizes` (JSON array of valid dimensions) |
|
||||
| **Status** | `is_active`, `is_default`, `sort_order` |
|
||||
| **Metadata** | `notes`, `release_date`, `deprecation_date` |
|
||||
|
||||
**Seeded Models**:
|
||||
- **OpenAI Text**: gpt-4o-mini, gpt-4o, gpt-5.1(default)
|
||||
- **OpenAI Image**: dall-e-3 (default)
|
||||
- **Runware**: runware:97@1, google:4@2
|
||||
|
||||
---
|
||||
|
||||
### 2. Global Integration Settings
|
||||
|
||||
**Model**: `GlobalIntegrationSettings` (Singleton - always pk=1)
|
||||
|
||||
Stores **platform-wide** API keys and default settings used by ALL accounts.
|
||||
|
||||
| Provider | API Key Field | Default Model | Parameters |
|
||||
|----------|---------------|---------------|------------|
|
||||
| **OpenAI** | `openai_api_key` | gpt-5.1 | temperature: 0.7, max_tokens: 8192 |
|
||||
| **DALL-E** | `dalle_api_key` | dall-e-3 | size: 1024x1024 |
|
||||
| **Runware** | `runware_api_key` | runware:97@1 & google:4@2 | — |
|
||||
|
||||
|
||||
**Default Provider Settings**:
|
||||
- `default_text_provider`: 'openai'
|
||||
- `default_image_service`: 'openai'
|
||||
|
||||
**Universal Image Settings**:
|
||||
- `image_quality`: standard/hd
|
||||
- `image_style`: photorealistic, illustration, etc.
|
||||
- `max_in_article_images`: Default 2
|
||||
|
||||
|
||||
**Critical Security Rule**: API keys exist ONLY in GlobalIntegrationSettings - never stored at account/user level.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 4. User-Specific Record Creation (Save Mechanism)
|
||||
|
||||
**Three-Tier Hierarchy**:
|
||||
```
|
||||
Global (Platform) → Account (Tenant) → User (Personal)
|
||||
```
|
||||
|
||||
#### Models Involved
|
||||
|
||||
| Model | Scope | Unique Key |
|
||||
|-------|-------|------------|
|
||||
| `GlobalIntegrationSettings` | Platform-wide | Singleton (pk=1) |
|
||||
| `AccountSettings` | Per-tenant | account + key |
|
||||
| `IntegrationSettings` | Per-tenant overrides | account + integration_type |
|
||||
| `UserSettings` | Per-user preferences | user + account + key |
|
||||
|
||||
#### Save Flow When User Changes Config
|
||||
|
||||
1. **Frontend** calls POST/PUT to `/api/v1/system/settings/user/`
|
||||
2. **Backend** ViewSet extracts user and account from authenticated request
|
||||
3. **Check existing**: Query for existing setting with same user + account + key
|
||||
4. **Create or Update**:
|
||||
- If not exists → `serializer.save(user=user, account=account)` creates new record
|
||||
- If exists → Updates the `value` JSON field
|
||||
5. **Validation**: Schema validation runs against `SETTINGS_SCHEMAS` before save
|
||||
6. **Response** returns the saved setting object
|
||||
|
||||
#### Integration Override Pattern
|
||||
|
||||
For AI/integration settings specifically:
|
||||
1. User changes model/temperature (NOT API keys)
|
||||
2. System strips any API key fields from request (security)
|
||||
3. `IntegrationSettings.objects.get_or_create(account=account, integration_type=type)`
|
||||
4. Only allowed override fields saved in `config` JSON field
|
||||
5. On read, system merges: Global defaults → Account overrides
|
||||
|
||||
---
|
||||
|
||||
### 6. Image Generation: Internal Cost vs Customer Credit Allocation
|
||||
|
||||
#### Internal Cost (What Platform Pays to Providers)
|
||||
|
||||
| Model | Provider | Cost Per Image (USD) |
|
||||
|-------|----------|---------------------|
|
||||
| dall-e-3 | OpenAI | $0.05 |
|
||||
| runware:97@1 | Runware - Hi Dream Full | ~$0.013 |
|
||||
| google:4@2 | Runware - Google Nano Banaan | ~$0.15 |
|
||||
|
||||
**Storage**: AIModelConfig.`cost_per_image` field + legacy `IMAGE_MODEL_RATES` constants
|
||||
|
||||
!!! This need to be fixed rates tobe laoded and used form configured AI Models !! not from hard coded location
|
||||
|
||||
#### Customer Credit Cost (What Customer Pays)
|
||||
|
||||
| Operation | Credits Charged | Price per Credit | Min Charge |
|
||||
|-----------|-----------------|------------------|------------|
|
||||
| Image Generation | 5 credits | $0.02 | $0.10 |
|
||||
| Image Prompt Extraction | 2 credits | $0.01 | $0.02 |
|
||||
|
||||
!!!morre robust image gneartion csoting and pricing mecahnishm required, withotu long chains or workarounds!!!
|
||||
|
||||
**Configuration Model**: `CreditCostConfig`
|
||||
- `tokens_per_credit`: 50 (image gen uses fewer tokens per credit = higher cost)
|
||||
- `min_credits`: 5
|
||||
- `price_per_credit_usd`: $0.02
|
||||
|
||||
#### Margin Calculation
|
||||
|
||||
| Metric | DALL-E 3 Example |
|
||||
|--------|------------------|
|
||||
| Provider Cost | $0.040 |
|
||||
| Customer Charge | $0.10 (5 credits × $0.02) |
|
||||
| **Margin** | $0.06 (60% of customer charge) |
|
||||
| **Markup** | ~150% |
|
||||
|
||||
**Flow**:
|
||||
1. **Before AI call**: Calculate required credits based on image count
|
||||
2. **Check balance**: Verify account has sufficient credits
|
||||
3. **Deduct credits**: Remove from balance, log transaction
|
||||
4. **Execute**: Make AI provider API call
|
||||
5. **Track**: `CreditUsageLog` stores both `credits_used` (customer) and `cost_usd` (actual)
|
||||
|
||||
**Revenue Analytics** queries `CreditUsageLog` to calculate:
|
||||
- Total revenue = Σ(credits_used × credit_price)
|
||||
- Total cost = Σ(cost_usd from provider)
|
||||
- Margin = Revenue - Cost
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
The IGNY8 platform implements a sophisticated multi-tier configuration system:
|
||||
|
||||
- **AI configuration** is database-driven with fallback to legacy constants
|
||||
- **Global settings** hold platform API keys; accounts only override model/parameters
|
||||
- **User settings** create per-user records keyed by user + account + key combination
|
||||
- **Credit system** charges customers a markup (~150%) over actual provider costs
|
||||
- **Several fields are deprecated** including `mobile_image_size`, `reference_id`, and the `get_model()` method
|
||||
218
your-analysis.md
218
your-analysis.md
@@ -1,218 +0,0 @@
|
||||
## IGNY8 AI & Configuration Settings Report
|
||||
|
||||
---
|
||||
|
||||
### 1. AI Mode Configuration
|
||||
|
||||
**Architecture**: Two-tier database-driven model configuration system
|
||||
|
||||
#### AIModelConfig Model (Primary)
|
||||
Stores all AI model configurations in database, replacing legacy hardcoded constants.
|
||||
|
||||
**Model Types**:
|
||||
- Text Generation
|
||||
- Image Generation
|
||||
- Embedding
|
||||
|
||||
**Supported Providers**:
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Runware
|
||||
- Google
|
||||
|
||||
**Key Configuration Fields**:
|
||||
|
||||
| Category | Fields |
|
||||
|----------|--------|
|
||||
| **Identity** | `model_id`, `display_name`, `model_type`, `provider` |
|
||||
| **Text Pricing** | `input_token_rate`, `output_token_rate` (per 1M tokens in USD) |
|
||||
| **Text Limits** | `max_input_tokens`, `max_output_tokens` |
|
||||
| **Image Pricing** | `cost_per_image` (fixed USD per image) |
|
||||
| **Image Config** | `available_sizes` (JSON array of valid dimensions) |
|
||||
| **Capabilities** | `supports_json_mode`, `supports_vision`, `supports_tools` |
|
||||
| **Status** | `is_active`, `is_default`, `sort_order` |
|
||||
| **Metadata** | `notes`, `release_date`, `deprecation_date` |
|
||||
|
||||
**Seeded Models**:
|
||||
- **OpenAI Text**: gpt-4.1 (default), gpt-4o-mini, gpt-4o, gpt-5.1, gpt-5.2
|
||||
- **Anthropic Text**: claude-3-5-sonnet, claude-3-opus, claude-3-haiku variants
|
||||
- **OpenAI Image**: dall-e-3 (default), dall-e-2, gpt-image-1, gpt-image-1-mini
|
||||
- **Runware/Bria**: runware:100@1, bria-2.3, bria-2.3-fast, bria-2.2
|
||||
|
||||
---
|
||||
|
||||
### 2. Global Integration Settings
|
||||
|
||||
**Model**: `GlobalIntegrationSettings` (Singleton - always pk=1)
|
||||
|
||||
Stores **platform-wide** API keys and default settings used by ALL accounts.
|
||||
|
||||
| Provider | API Key Field | Default Model | Parameters |
|
||||
|----------|---------------|---------------|------------|
|
||||
| **OpenAI** | `openai_api_key` | gpt-4o-mini | temperature: 0.7, max_tokens: 8192 |
|
||||
| **Anthropic** | `anthropic_api_key` | claude-3-5-sonnet-20241022 | temperature: 0.7, max_tokens: 8192 |
|
||||
| **DALL-E** | `dalle_api_key` | dall-e-3 | size: 1024x1024 |
|
||||
| **Runware** | `runware_api_key` | runware:97@1 | — |
|
||||
| **Bria** | `bria_api_key` | bria-2.3 | — |
|
||||
|
||||
**Default Provider Settings**:
|
||||
- `default_text_provider`: 'openai' or 'anthropic'
|
||||
- `default_image_service`: 'openai' or 'runware'
|
||||
|
||||
**Universal Image Settings**:
|
||||
- `image_quality`: standard/hd
|
||||
- `image_style`: photorealistic, illustration, etc.
|
||||
- `max_in_article_images`: Default 2
|
||||
- `desktop_image_size`: Default 1024x1024
|
||||
|
||||
**Critical Security Rule**: API keys exist ONLY in GlobalIntegrationSettings - never stored at account/user level.
|
||||
|
||||
---
|
||||
|
||||
### 3. Frontend Configuration Settings Panel
|
||||
|
||||
**Structure**: Three main setting hierarchies
|
||||
|
||||
#### Account Section (`/account/*`)
|
||||
| Page | Tabs | Purpose |
|
||||
|------|------|---------|
|
||||
| Account Settings | Account, Profile, Team | User account management |
|
||||
| Content Settings | Content, Publishing, Images | Content creation workflow |
|
||||
| Plans & Billing | Plan, Upgrade, Invoices | Subscription management |
|
||||
| Usage Analytics | Overview, Credits, Activity | Usage tracking |
|
||||
|
||||
#### Settings Section (`/settings/*`)
|
||||
| Page | Purpose |
|
||||
|------|---------|
|
||||
| General | Table settings, app preferences |
|
||||
| System | Global platform settings |
|
||||
| AI Settings | AI model configuration |
|
||||
| Integration | API integrations (Admin only) |
|
||||
| Publishing | Publishing destinations & rules |
|
||||
|
||||
#### Site-Level Settings (`/sites/:id/settings`)
|
||||
**Tabs**: general, content-generation, image-settings, integrations, publishing, content-types
|
||||
|
||||
**State Management**: Zustand store with persistence middleware (`useSettingsStore`)
|
||||
|
||||
**Available Settings Keys**:
|
||||
- `table_settings`: records_per_page, default_sort, sort_direction
|
||||
- `user_preferences`: theme, language, notifications
|
||||
- `ai_settings`: model overrides, temperature, max_tokens
|
||||
- `planner_automation`: automation rules
|
||||
- `writer_automation`: content generation rules
|
||||
|
||||
---
|
||||
|
||||
### 4. User-Specific Record Creation (Save Mechanism)
|
||||
|
||||
**Three-Tier Hierarchy**:
|
||||
```
|
||||
Global (Platform) → Account (Tenant) → User (Personal)
|
||||
```
|
||||
|
||||
#### Models Involved
|
||||
|
||||
| Model | Scope | Unique Key |
|
||||
|-------|-------|------------|
|
||||
| `GlobalIntegrationSettings` | Platform-wide | Singleton (pk=1) |
|
||||
| `AccountSettings` | Per-tenant | account + key |
|
||||
| `IntegrationSettings` | Per-tenant overrides | account + integration_type |
|
||||
| `UserSettings` | Per-user preferences | user + account + key |
|
||||
|
||||
#### Save Flow When User Changes Config
|
||||
|
||||
1. **Frontend** calls POST/PUT to `/api/v1/system/settings/user/`
|
||||
2. **Backend** ViewSet extracts user and account from authenticated request
|
||||
3. **Check existing**: Query for existing setting with same user + account + key
|
||||
4. **Create or Update**:
|
||||
- If not exists → `serializer.save(user=user, account=account)` creates new record
|
||||
- If exists → Updates the `value` JSON field
|
||||
5. **Validation**: Schema validation runs against `SETTINGS_SCHEMAS` before save
|
||||
6. **Response** returns the saved setting object
|
||||
|
||||
#### Integration Override Pattern
|
||||
|
||||
For AI/integration settings specifically:
|
||||
1. User changes model/temperature (NOT API keys)
|
||||
2. System strips any API key fields from request (security)
|
||||
3. `IntegrationSettings.objects.get_or_create(account=account, integration_type=type)`
|
||||
4. Only allowed override fields saved in `config` JSON field
|
||||
5. On read, system merges: Global defaults → Account overrides
|
||||
|
||||
---
|
||||
|
||||
### 5. Unused/Deprecated Fields
|
||||
|
||||
| Field/Item | Location | Status |
|
||||
|------------|----------|--------|
|
||||
| `reference_id` | CreditTransaction model | **DEPRECATED** - Use `payment` FK instead |
|
||||
| `mobile_image_size` | GlobalIntegrationSettings | **REMOVED** - No longer needed |
|
||||
| `max_items` parameter | validators.py | **Deprecated** - No longer enforced |
|
||||
| `get_model()` method | AICore class | **DEPRECATED** - Raises ValueError, model must be passed directly |
|
||||
| `run_request()` method | AICore class | **DEPRECATED** - Redirects to `run_ai_request()` |
|
||||
| `persist_task_metadata_to_content()` | MetadataMappingService | **DEPRECATED** - Content model no longer has task field |
|
||||
| `DeploymentService` | publishing/services/ | **DEPRECATED** - Legacy SiteBlueprint service |
|
||||
| SiteBlueprint model references | Multiple files | **REMOVED** - SiteBuilder deprecated |
|
||||
|
||||
---
|
||||
|
||||
### 6. Image Generation: Internal Cost vs Customer Credit Allocation
|
||||
|
||||
#### Internal Cost (What Platform Pays to Providers)
|
||||
|
||||
| Model | Provider | Cost Per Image (USD) |
|
||||
|-------|----------|---------------------|
|
||||
| dall-e-3 | OpenAI | $0.040 |
|
||||
| dall-e-2 | OpenAI | $0.020 |
|
||||
| gpt-image-1 | OpenAI | $0.042 |
|
||||
| gpt-image-1-mini | OpenAI | $0.011 |
|
||||
| runware:100@1 | Runware | ~$0.008-0.009 |
|
||||
| bria-2.3 | Bria | ~$0.015 |
|
||||
|
||||
**Storage**: AIModelConfig.`cost_per_image` field + legacy `IMAGE_MODEL_RATES` constants
|
||||
|
||||
#### Customer Credit Cost (What Customer Pays)
|
||||
|
||||
| Operation | Credits Charged | Price per Credit | Min Charge |
|
||||
|-----------|-----------------|------------------|------------|
|
||||
| Image Generation | 5 credits | $0.02 | $0.10 |
|
||||
| Image Prompt Extraction | 2 credits | $0.01 | $0.02 |
|
||||
|
||||
**Configuration Model**: `CreditCostConfig`
|
||||
- `tokens_per_credit`: 50 (image gen uses fewer tokens per credit = higher cost)
|
||||
- `min_credits`: 5
|
||||
- `price_per_credit_usd`: $0.02
|
||||
|
||||
#### Margin Calculation
|
||||
|
||||
| Metric | DALL-E 3 Example |
|
||||
|--------|------------------|
|
||||
| Provider Cost | $0.040 |
|
||||
| Customer Charge | $0.10 (5 credits × $0.02) |
|
||||
| **Margin** | $0.06 (60% of customer charge) |
|
||||
| **Markup** | ~150% |
|
||||
|
||||
**Flow**:
|
||||
1. **Before AI call**: Calculate required credits based on image count
|
||||
2. **Check balance**: Verify account has sufficient credits
|
||||
3. **Deduct credits**: Remove from balance, log transaction
|
||||
4. **Execute**: Make AI provider API call
|
||||
5. **Track**: `CreditUsageLog` stores both `credits_used` (customer) and `cost_usd` (actual)
|
||||
|
||||
**Revenue Analytics** queries `CreditUsageLog` to calculate:
|
||||
- Total revenue = Σ(credits_used × credit_price)
|
||||
- Total cost = Σ(cost_usd from provider)
|
||||
- Margin = Revenue - Cost
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
The IGNY8 platform implements a sophisticated multi-tier configuration system:
|
||||
|
||||
- **AI configuration** is database-driven with fallback to legacy constants
|
||||
- **Global settings** hold platform API keys; accounts only override model/parameters
|
||||
- **User settings** create per-user records keyed by user + account + key combination
|
||||
- **Credit system** charges customers a markup (~150%) over actual provider costs
|
||||
- **Several fields are deprecated** including `mobile_image_size`, `reference_id`, and the `get_model()` method
|
||||
Reference in New Issue
Block a user