many changes for modules widgets and colors and styling
This commit is contained in:
@@ -17,6 +17,10 @@ import SignUp from "./pages/AuthPages/SignUp";
|
||||
import Payment from "./pages/Payment";
|
||||
import NotFound from "./pages/OtherPage/NotFound";
|
||||
|
||||
// Legal pages - Public
|
||||
const Terms = lazy(() => import("./pages/legal/Terms"));
|
||||
const Privacy = lazy(() => import("./pages/legal/Privacy"));
|
||||
|
||||
// Lazy load all other pages - only loads when navigated to
|
||||
const Home = lazy(() => import("./pages/Dashboard/Home"));
|
||||
|
||||
@@ -133,6 +137,10 @@ export default function App() {
|
||||
<Route path="/signup" element={<SignUp />} />
|
||||
<Route path="/payment" element={<Payment />} />
|
||||
|
||||
{/* Legal Pages - Public */}
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
|
||||
{/* Protected Routes - Require Authentication */}
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -500,8 +500,14 @@ export default function SignUpFormUnified({
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<Checkbox className="w-5 h-5 mt-0.5" checked={isChecked} onChange={setIsChecked} />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
By creating an account means you agree to the <span className="text-gray-800 dark:text-white/90">Terms and Conditions</span>, and our{' '}
|
||||
<span className="text-gray-800 dark:text-white">Privacy Policy</span>
|
||||
By creating an account means you agree to the{' '}
|
||||
<Link to="/terms" className="text-brand-500 hover:text-brand-600 dark:text-brand-400 hover:underline">
|
||||
Terms and Conditions
|
||||
</Link>
|
||||
, and our{' '}
|
||||
<Link to="/privacy" className="text-brand-500 hover:text-brand-600 dark:text-brand-400 hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* ColumnSelector Component
|
||||
* Dropdown with checkboxes to show/hide table columns
|
||||
* Dropdown opens in the direction with most available space
|
||||
*/
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDownIcon } from '../../icons';
|
||||
import Checkbox from '../form/input/Checkbox';
|
||||
|
||||
@@ -22,6 +24,7 @@ export default function ColumnSelector({
|
||||
compact = false,
|
||||
}: ColumnSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -46,6 +49,98 @@ export default function ColumnSelector({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Calculate dropdown position when opened
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 384; // max-h-96 = 24rem = 384px
|
||||
const dropdownWidth = 224; // w-56 = 14rem = 224px
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const margin = 8;
|
||||
|
||||
// Calculate space above and below
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom - margin;
|
||||
const spaceAbove = buttonRect.top - margin;
|
||||
|
||||
// Determine vertical position - prefer below if enough space
|
||||
let top: number;
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
// Open below
|
||||
top = buttonRect.bottom + margin;
|
||||
} else {
|
||||
// Open above
|
||||
top = buttonRect.top - dropdownHeight - margin;
|
||||
}
|
||||
|
||||
// Ensure dropdown doesn't go off top of screen
|
||||
top = Math.max(margin, top);
|
||||
|
||||
// Calculate horizontal position - align to right edge of button
|
||||
let left = buttonRect.right - dropdownWidth;
|
||||
// Ensure dropdown doesn't go off left edge
|
||||
left = Math.max(margin, left);
|
||||
// Ensure dropdown doesn't go off right edge
|
||||
if (left + dropdownWidth > viewportWidth - margin) {
|
||||
left = viewportWidth - dropdownWidth - margin;
|
||||
}
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${dropdownWidth}px`,
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Recalculate on scroll/resize
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleReposition = () => {
|
||||
if (buttonRef.current) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 384;
|
||||
const dropdownWidth = 224;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const margin = 8;
|
||||
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom - margin;
|
||||
const spaceAbove = buttonRect.top - margin;
|
||||
|
||||
let top: number;
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
top = buttonRect.bottom + margin;
|
||||
} else {
|
||||
top = buttonRect.top - dropdownHeight - margin;
|
||||
}
|
||||
top = Math.max(margin, top);
|
||||
|
||||
let left = buttonRect.right - dropdownWidth;
|
||||
left = Math.max(margin, left);
|
||||
if (left + dropdownWidth > viewportWidth - margin) {
|
||||
left = viewportWidth - dropdownWidth - margin;
|
||||
}
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${dropdownWidth}px`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleReposition, true);
|
||||
window.addEventListener('resize', handleReposition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleReposition, true);
|
||||
window.removeEventListener('resize', handleReposition);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const visibleCount = visibleColumns.size;
|
||||
const totalCount = columns.length;
|
||||
|
||||
@@ -90,10 +185,11 @@ export default function ColumnSelector({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 z-50 max-h-96 overflow-y-auto"
|
||||
style={dropdownStyle}
|
||||
className="rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 z-[9999] max-h-96 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide border-b border-gray-200 dark:border-gray-700 mb-1">
|
||||
@@ -137,7 +233,8 @@ export default function ColumnSelector({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,15 +35,22 @@ interface AIOperationsWidgetProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const operationConfig: Record<string, { label: string; icon: typeof GroupIcon; color: string }> = {
|
||||
clustering: { label: 'Clustering', icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||
ideas: { label: 'Ideas', icon: BoltIcon, color: 'text-warning-600 dark:text-warning-400' },
|
||||
content: { label: 'Content', icon: FileTextIcon, color: 'text-success-600 dark:text-success-400' },
|
||||
images: { label: 'Images', icon: FileIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||
/**
|
||||
* Operation config with solid colored backgrounds matching Automation/Usage pages:
|
||||
* - Clustering: purple
|
||||
* - Ideas: warning/amber
|
||||
* - Content: success/green
|
||||
* - Images: purple
|
||||
*/
|
||||
const operationConfig: Record<string, { label: string; icon: typeof GroupIcon; gradient: string }> = {
|
||||
clustering: { label: 'Clustering', icon: GroupIcon, gradient: 'from-purple-500 to-purple-600' },
|
||||
ideas: { label: 'Ideas', icon: BoltIcon, gradient: 'from-warning-500 to-warning-600' },
|
||||
content: { label: 'Content', icon: FileTextIcon, gradient: 'from-success-500 to-success-600' },
|
||||
images: { label: 'Images', icon: FileIcon, gradient: 'from-purple-500 to-purple-600' },
|
||||
};
|
||||
|
||||
// Default config for unknown operation types
|
||||
const defaultConfig = { label: 'Other', icon: BoltIcon, color: 'text-gray-600 dark:text-gray-400' };
|
||||
const defaultConfig = { label: 'Other', icon: BoltIcon, gradient: 'from-gray-500 to-gray-600' };
|
||||
|
||||
const periods = [
|
||||
{ value: '7d', label: '7 days' },
|
||||
@@ -117,7 +124,9 @@ export default function AIOperationsWidget({ data, onPeriodChange, loading }: AI
|
||||
className="flex items-center py-2 border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
<div className={`p-1.5 rounded-lg bg-gradient-to-br ${config.gradient} shadow-sm`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-base text-gray-800 dark:text-gray-200">
|
||||
{config.label}
|
||||
</span>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* ModuleMetricsFooter - Compact metrics footer for table pages
|
||||
* Shows module-specific metrics at the bottom of table pages
|
||||
* Uses standard EnhancedMetricCard and ProgressBar components
|
||||
* Follows standard app design system and color scheme
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import EnhancedMetricCard, { MetricCardProps } from './EnhancedMetricCard';
|
||||
import { ProgressBar } from '../ui/progress';
|
||||
|
||||
export interface MetricItem {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: MetricCardProps['accentColor'];
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface ProgressMetric {
|
||||
label: string;
|
||||
value: number; // 0-100
|
||||
color?: 'primary' | 'success' | 'warning' | 'purple';
|
||||
}
|
||||
|
||||
interface ModuleMetricsFooterProps {
|
||||
metrics: MetricItem[];
|
||||
progress?: ProgressMetric;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ModuleMetricsFooter({
|
||||
metrics,
|
||||
progress,
|
||||
className = ''
|
||||
}: ModuleMetricsFooterProps) {
|
||||
if (metrics.length === 0 && !progress) return null;
|
||||
|
||||
const progressColors = {
|
||||
primary: 'bg-[var(--color-primary)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
purple: 'bg-[var(--color-purple)]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 ${className}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Metrics Grid */}
|
||||
{metrics.length > 0 && (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 ${metrics.length > 2 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} ${metrics.length > 3 ? 'xl:grid-cols-4' : ''} gap-4`}>
|
||||
{metrics.map((metric, index) => (
|
||||
<EnhancedMetricCard
|
||||
key={index}
|
||||
title={metric.title}
|
||||
value={metric.value}
|
||||
subtitle={metric.subtitle}
|
||||
icon={metric.icon}
|
||||
accentColor={metric.accentColor}
|
||||
href={metric.href}
|
||||
onClick={metric.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<ProgressBar
|
||||
value={progress.value}
|
||||
color={progress.color === 'success' ? 'success' : progress.color === 'warning' ? 'warning' : 'primary'}
|
||||
size="md"
|
||||
showLabel={true}
|
||||
label={progress.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ interface QuickActionsWidgetProps {
|
||||
onAddKeywords?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow steps with solid colored backgrounds matching Automation/Usage pages:
|
||||
* - Keywords: brand/primary (blue)
|
||||
* - Clusters: purple
|
||||
* - Ideas: warning/amber
|
||||
* - Tasks: brand/primary (blue)
|
||||
* - Content: success/green
|
||||
* - Images: purple
|
||||
* - Review: warning/amber
|
||||
* - Publish: success/green
|
||||
*/
|
||||
const workflowSteps = [
|
||||
{
|
||||
num: 1,
|
||||
@@ -28,7 +39,7 @@ const workflowSteps = [
|
||||
description: 'Import your target keywords manually or from CSV',
|
||||
href: '/planner/keyword-opportunities',
|
||||
actionLabel: 'Add',
|
||||
color: 'text-brand-600 dark:text-brand-400',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
@@ -37,7 +48,7 @@ const workflowSteps = [
|
||||
description: 'AI groups related keywords into content clusters',
|
||||
href: '/planner/clusters',
|
||||
actionLabel: 'Cluster',
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
gradient: 'from-purple-500 to-purple-600',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
@@ -46,7 +57,7 @@ const workflowSteps = [
|
||||
description: 'Create content ideas from your keyword clusters',
|
||||
href: '/planner/ideas',
|
||||
actionLabel: 'Ideas',
|
||||
color: 'text-warning-600 dark:text-warning-400',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
@@ -55,7 +66,7 @@ const workflowSteps = [
|
||||
description: 'Convert approved ideas into content tasks',
|
||||
href: '/writer/tasks',
|
||||
actionLabel: 'Tasks',
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
@@ -64,7 +75,7 @@ const workflowSteps = [
|
||||
description: 'AI writes SEO-optimized articles from tasks',
|
||||
href: '/writer/content',
|
||||
actionLabel: 'Write',
|
||||
color: 'text-success-600 dark:text-success-400',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
@@ -73,7 +84,7 @@ const workflowSteps = [
|
||||
description: 'Create featured images and media for articles',
|
||||
href: '/writer/images',
|
||||
actionLabel: 'Images',
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
gradient: 'from-purple-500 to-purple-600',
|
||||
},
|
||||
{
|
||||
num: 7,
|
||||
@@ -82,7 +93,7 @@ const workflowSteps = [
|
||||
description: 'Quality check and approve generated content',
|
||||
href: '/writer/review',
|
||||
actionLabel: 'Review',
|
||||
color: 'text-warning-600 dark:text-warning-400',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
},
|
||||
{
|
||||
num: 8,
|
||||
@@ -91,7 +102,7 @@ const workflowSteps = [
|
||||
description: 'Push approved content to your WordPress site',
|
||||
href: '/writer/published',
|
||||
actionLabel: 'Publish',
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -128,13 +139,13 @@ export default function QuickActionsWidget({ onAddKeywords }: QuickActionsWidget
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
>
|
||||
{/* Step Number */}
|
||||
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||
<span className={`w-6 h-6 flex items-center justify-center rounded-full bg-gradient-to-br ${step.gradient} text-sm font-semibold text-white flex-shrink-0 shadow-sm`}>
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{/* Icon with solid gradient background */}
|
||||
<div className={`flex-shrink-0 p-1.5 rounded-lg bg-gradient-to-br ${step.gradient} shadow-sm`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
@@ -172,13 +183,13 @@ export default function QuickActionsWidget({ onAddKeywords }: QuickActionsWidget
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
>
|
||||
{/* Step Number */}
|
||||
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||
<span className={`w-6 h-6 flex items-center justify-center rounded-full bg-gradient-to-br ${step.gradient} text-sm font-semibold text-white flex-shrink-0 shadow-sm`}>
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{/* Icon with solid gradient background */}
|
||||
<div className={`flex-shrink-0 p-1.5 rounded-lg bg-gradient-to-br ${step.gradient} shadow-sm`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
@@ -216,13 +227,13 @@ export default function QuickActionsWidget({ onAddKeywords }: QuickActionsWidget
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
>
|
||||
{/* Step Number */}
|
||||
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||
<span className={`w-6 h-6 flex items-center justify-center rounded-full bg-gradient-to-br ${step.gradient} text-sm font-semibold text-white flex-shrink-0 shadow-sm`}>
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{/* Icon with solid gradient background */}
|
||||
<div className={`flex-shrink-0 p-1.5 rounded-lg bg-gradient-to-br ${step.gradient} shadow-sm`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
|
||||
@@ -30,19 +30,19 @@ interface RecentActivityWidgetProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const activityConfig: Record<string, { icon: typeof GroupIcon; color: string; bgColor: string }> = {
|
||||
clustering: { icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/40' },
|
||||
ideas: { icon: BoltIcon, color: 'text-warning-600 dark:text-warning-400', bgColor: 'bg-warning-100 dark:bg-warning-900/40' },
|
||||
content: { icon: FileTextIcon, color: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/40' },
|
||||
images: { icon: FileIcon, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/40' },
|
||||
published: { icon: PaperPlaneIcon, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/40' },
|
||||
keywords: { icon: ListIcon, color: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/40' },
|
||||
error: { icon: AlertIcon, color: 'text-error-600 dark:text-error-400', bgColor: 'bg-error-100 dark:bg-error-900/40' },
|
||||
sync: { icon: CheckCircleIcon, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/40' },
|
||||
const activityConfig: Record<string, { icon: typeof GroupIcon; gradient: string }> = {
|
||||
clustering: { icon: GroupIcon, gradient: 'from-purple-500 to-purple-600' },
|
||||
ideas: { icon: BoltIcon, gradient: 'from-warning-500 to-warning-600' },
|
||||
content: { icon: FileTextIcon, gradient: 'from-success-500 to-success-600' },
|
||||
images: { icon: FileIcon, gradient: 'from-purple-500 to-purple-600' },
|
||||
published: { icon: PaperPlaneIcon, gradient: 'from-success-500 to-success-600' },
|
||||
keywords: { icon: ListIcon, gradient: 'from-brand-500 to-brand-600' },
|
||||
error: { icon: AlertIcon, gradient: 'from-error-500 to-error-600' },
|
||||
sync: { icon: CheckCircleIcon, gradient: 'from-success-500 to-success-600' },
|
||||
};
|
||||
|
||||
// Default config for unknown activity types
|
||||
const defaultActivityConfig = { icon: BoltIcon, color: 'text-gray-600 dark:text-gray-400', bgColor: 'bg-gray-100 dark:bg-gray-900/40' };
|
||||
const defaultActivityConfig = { icon: BoltIcon, gradient: 'from-gray-500 to-gray-600' };
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
@@ -95,8 +95,8 @@ export default function RecentActivityWidget({ activities, loading }: RecentActi
|
||||
|
||||
const content = (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
<div className={`w-9 h-9 rounded-lg bg-gradient-to-br ${config.gradient} flex items-center justify-center flex-shrink-0 shadow-md`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base text-gray-800 dark:text-gray-200 line-clamp-1">
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
/**
|
||||
* ThreeWidgetFooter - 3-Column Layout for Table Page Footers
|
||||
* StandardThreeWidgetFooter - Enhanced 3-Column Layout with Standardized Widgets
|
||||
*
|
||||
* Design from Section 3 of COMPREHENSIVE-AUDIT-REPORT.md:
|
||||
* ┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
* │ WIDGET 1: PAGE METRICS │ WIDGET 2: MODULE STATS │ WIDGET 3: COMPLETION │
|
||||
* │ (Current Page Progress) │ (Full Module Overview) │ (Both Modules Stats) │
|
||||
* │ ~33.3% width │ ~33.3% width │ ~33.3% width │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
* This component provides a consistent 3-widget footer layout:
|
||||
* - Widget 1: Page Progress (page-specific, passed as prop)
|
||||
* - Widget 2: Module Stats (standardized via useModuleStats hook)
|
||||
* - Widget 3: Workflow Completion (standardized via useWorkflowStats hook)
|
||||
*
|
||||
* STYLING: Uses CSS tokens from styles/tokens.css:
|
||||
* - --color-primary: Brand blue for primary actions/bars
|
||||
* - --color-success: Green for success states
|
||||
* - --color-warning: Amber for warnings
|
||||
* - --color-purple: Purple accent
|
||||
* Use this component for consistent data across all Planner and Writer module pages.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
import WorkflowCompletionWidget from './WorkflowCompletionWidget';
|
||||
import StandardizedModuleWidget, { ModuleType } from './StandardizedModuleWidget';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// TYPE DEFINITIONS (moved from ThreeWidgetFooter.tsx)
|
||||
// ============================================================================
|
||||
|
||||
/** Submodule color type - matches headerMetrics accentColor */
|
||||
@@ -35,6 +31,10 @@ export interface PageProgressWidget {
|
||||
hint?: string;
|
||||
/** The submodule's accent color - progress bar uses this */
|
||||
submoduleColor?: SubmoduleColor;
|
||||
/** Optional credits consumed for AI functions on this page */
|
||||
creditsConsumed?: number;
|
||||
/** Contextual status insight - guidance message based on current state */
|
||||
statusInsight?: string;
|
||||
}
|
||||
|
||||
/** Widget 2: Module Stats - Pipeline flow with arrows and progress bars */
|
||||
@@ -57,28 +57,21 @@ export interface ModuleStatsWidget {
|
||||
links: Array<{ label: string; href: string }>;
|
||||
}
|
||||
|
||||
/** Widget 3: Completion - Tree structure with bars for both modules */
|
||||
export interface CompletionItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface CompletionWidget {
|
||||
title: string;
|
||||
plannerItems: CompletionItem[];
|
||||
writerItems: CompletionItem[];
|
||||
creditsUsed?: number;
|
||||
operationsCount?: number;
|
||||
analyticsHref?: string;
|
||||
}
|
||||
|
||||
/** Main component props */
|
||||
export interface ThreeWidgetFooterProps {
|
||||
export interface StandardThreeWidgetFooterProps {
|
||||
pageProgress: PageProgressWidget;
|
||||
moduleStats: ModuleStatsWidget;
|
||||
completion: CompletionWidget;
|
||||
/** @deprecated Use `module` prop instead for standardized data. This is kept for backward compatibility. */
|
||||
moduleStats?: ModuleStatsWidget;
|
||||
/** Module type for standardized module stats widget */
|
||||
module?: ModuleType;
|
||||
submoduleColor?: SubmoduleColor;
|
||||
/** Show credits consumption in workflow widget */
|
||||
showCredits?: boolean;
|
||||
/** Analytics href for the workflow widget */
|
||||
analyticsHref?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -104,7 +97,7 @@ function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PagePro
|
||||
const progressColor = widget.submoduleColor || widget.progress.color || submoduleColor;
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
@@ -144,14 +137,31 @@ function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PagePro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits consumed (for AI function pages) */}
|
||||
{widget.creditsConsumed !== undefined && widget.creditsConsumed > 0 && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 mb-3">
|
||||
<span className="text-sm font-medium text-[color:var(--color-text-dim)] dark:text-gray-400">Credits Consumed</span>
|
||||
<span className="text-sm font-bold" style={{ color: 'var(--color-warning)' }}>{widget.creditsConsumed.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint with icon */}
|
||||
{widget.hint && (
|
||||
<div className="flex items-start gap-2 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<div className={`flex items-start gap-2 ${widget.creditsConsumed === undefined || widget.creditsConsumed === 0 ? 'pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800' : ''}`}>
|
||||
<LightBulbIcon className="w-5 h-5 flex-shrink-0 mt-0.5 text-warning-500" />
|
||||
<span className="text-sm font-medium text-brand-500">{widget.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Insight - contextual guidance */}
|
||||
{widget.statusInsight && (
|
||||
<div className="mt-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<p className="text-xs text-[color:var(--color-text-dim)] dark:text-gray-400 leading-relaxed">
|
||||
<span className="font-semibold" style={{ color: 'var(--color-primary)' }}>Next:</span>{' '}
|
||||
{widget.statusInsight}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +172,7 @@ function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PagePro
|
||||
|
||||
function ModuleStatsCard({ widget }: { widget: ModuleStatsWidget }) {
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
@@ -245,137 +255,43 @@ function ModuleStatsCard({ widget }: { widget: ModuleStatsWidget }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 3: COMPLETION
|
||||
// ============================================================================
|
||||
|
||||
function CompletionCard({ widget }: { widget: CompletionWidget }) {
|
||||
// Calculate max for proportional bars (across both columns)
|
||||
const allValues = [...widget.plannerItems, ...widget.writerItems].map(i => i.value);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
|
||||
const renderItem = (item: CompletionItem, isLast: boolean) => {
|
||||
const barWidth = (item.value / maxValue) * 100;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const color = item.color || 'blue';
|
||||
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2 py-1">
|
||||
{/* Tree prefix */}
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-500 font-mono text-xs w-5 flex-shrink-0">{prefix}</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-sm text-[color:var(--color-text)] dark:text-gray-300 flex-1 truncate">{item.label}</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-16 h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(color),
|
||||
width: `${Math.min(100, barWidth)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white tabular-nums w-10 text-right flex-shrink-0">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Two-column layout: Planner | Writer */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
{/* Planner Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2 text-brand-500">
|
||||
Planner
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.plannerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.plannerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Writer Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2 text-success-500">
|
||||
Writer
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.writerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.writerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats - Credits Used & Operations */}
|
||||
{(widget.creditsUsed !== undefined || widget.operationsCount !== undefined) && (
|
||||
<div className="flex items-center gap-4 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
{widget.creditsUsed !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Credits Used: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.creditsUsed.toLocaleString()}</strong>
|
||||
</span>
|
||||
)}
|
||||
{widget.creditsUsed !== undefined && widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-stroke)] dark:text-gray-600">│</span>
|
||||
)}
|
||||
{widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Operations: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.operationsCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Link */}
|
||||
{widget.analyticsHref && (
|
||||
<div className="pt-3 mt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<Link
|
||||
to={widget.analyticsHref}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1 text-brand-500"
|
||||
>
|
||||
View Full Analytics
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function ThreeWidgetFooter({
|
||||
export default function StandardThreeWidgetFooter({
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
module,
|
||||
submoduleColor = 'blue',
|
||||
showCredits = true,
|
||||
analyticsHref = '/account/usage',
|
||||
className = '',
|
||||
}: ThreeWidgetFooterProps) {
|
||||
}: StandardThreeWidgetFooterProps) {
|
||||
// Determine which module to show - prefer explicit module prop
|
||||
const moduleType: ModuleType = module || (moduleStats?.title?.toLowerCase().includes('planner') ? 'planner' : 'writer');
|
||||
|
||||
return (
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 w-full max-w-full overflow-hidden ${className}`}>
|
||||
{/*
|
||||
* Widget widths adjusted per requirements:
|
||||
* - Widget 1 (Page Progress): 28.3% (reduced by 5%)
|
||||
* - Widget 2 (Module Stats): 28.3% (reduced by 5%)
|
||||
* - Widget 3 (Workflow Completion): 43.4% (increased by 10%)
|
||||
* Using fr units to prevent overflow
|
||||
*/}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,28.3fr)_minmax(0,28.3fr)_minmax(0,43.4fr)] gap-4 w-full">
|
||||
<PageProgressCard widget={pageProgress} submoduleColor={submoduleColor} />
|
||||
<ModuleStatsCard widget={moduleStats} />
|
||||
<CompletionCard widget={completion} />
|
||||
{/* Use standardized module widget for consistent data */}
|
||||
<StandardizedModuleWidget module={moduleType} />
|
||||
<WorkflowCompletionWidget
|
||||
showCredits={showCredits}
|
||||
analyticsHref={analyticsHref}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export sub-components for flexibility
|
||||
export { PageProgressCard, ModuleStatsCard, CompletionCard };
|
||||
// Export sub-components
|
||||
export { PageProgressCard, ModuleStatsCard };
|
||||
279
frontend/src/components/dashboard/StandardizedModuleWidget.tsx
Normal file
279
frontend/src/components/dashboard/StandardizedModuleWidget.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* StandardizedModuleWidget - Centralized Module Stats Widget
|
||||
*
|
||||
* A standardized widget that displays consistent module pipeline stats
|
||||
* using the useModuleStats hook. This ensures all pages show the same
|
||||
* data for module-level statistics.
|
||||
*
|
||||
* Usage in Planner pages: Shows Planner pipeline (Keywords → Clusters → Ideas → Tasks)
|
||||
* Usage in Writer pages: Shows Writer pipeline (Tasks → Content → Images → Published)
|
||||
*
|
||||
* Also displays credits consumed per AI function:
|
||||
* - Planner: Clustering credits, Idea Generation credits
|
||||
* - Writer: Content Generation credits, Image Generation credits
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
import { useModuleStats } from '../../hooks/useModuleStats';
|
||||
import { getPipelineColors } from '../../config/colors.config';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
export type ModuleType = 'planner' | 'writer';
|
||||
|
||||
export interface StandardizedModuleWidgetProps {
|
||||
/** Which module pipeline to display */
|
||||
module: ModuleType;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function StandardizedModuleWidget({
|
||||
module,
|
||||
className = '',
|
||||
}: StandardizedModuleWidgetProps) {
|
||||
const { planner, writer, credits, loading } = useModuleStats();
|
||||
|
||||
// Get CSS variable colors at runtime for inline styles
|
||||
const pipelineColors = useMemo(() => getPipelineColors(module), [module]);
|
||||
|
||||
// Define Planner pipeline using colors from config
|
||||
const plannerPipeline = [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: planner.totalKeywords,
|
||||
fromHref: '/planner/keywords',
|
||||
toLabel: 'Clusters',
|
||||
toValue: planner.totalClusters,
|
||||
toHref: '/planner/clusters',
|
||||
progress: planner.totalKeywords > 0
|
||||
? Math.round((planner.keywordsMapped / planner.totalKeywords) * 100)
|
||||
: 0,
|
||||
color: pipelineColors[0], // primary
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: planner.totalClusters,
|
||||
fromHref: '/planner/clusters',
|
||||
toLabel: 'Ideas',
|
||||
toValue: planner.totalIdeas,
|
||||
toHref: '/planner/ideas',
|
||||
progress: planner.totalClusters > 0
|
||||
? Math.min(100, Math.round((planner.totalIdeas / planner.totalClusters) * 100))
|
||||
: 0,
|
||||
color: pipelineColors[1], // purple
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: planner.totalIdeas,
|
||||
fromHref: '/planner/ideas',
|
||||
toLabel: 'Tasks',
|
||||
toValue: planner.ideasConverted,
|
||||
toHref: '/writer/tasks',
|
||||
progress: planner.totalIdeas > 0
|
||||
? Math.round((planner.ideasConverted / planner.totalIdeas) * 100)
|
||||
: 0,
|
||||
color: pipelineColors[2], // warning
|
||||
},
|
||||
];
|
||||
|
||||
// Define Writer pipeline - using correct content structure
|
||||
// Content has status: draft, review, published
|
||||
// totalContent = drafts + review + published
|
||||
// Get writer colors from config
|
||||
const writerColors = useMemo(() => getPipelineColors('writer'), []);
|
||||
|
||||
const writerPipeline = [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: writer.totalTasks,
|
||||
fromHref: '/writer/tasks',
|
||||
toLabel: 'Content',
|
||||
toValue: writer.totalContent,
|
||||
toHref: '/writer/content',
|
||||
progress: writer.totalTasks > 0
|
||||
? Math.min(100, Math.round((writer.tasksCompleted / writer.totalTasks) * 100))
|
||||
: 0,
|
||||
color: writerColors[0], // primary
|
||||
},
|
||||
{
|
||||
fromLabel: 'Content',
|
||||
fromValue: writer.totalContent,
|
||||
fromHref: '/writer/content',
|
||||
toLabel: 'Images',
|
||||
toValue: writer.totalImages,
|
||||
toHref: '/writer/images',
|
||||
progress: writer.totalContent > 0
|
||||
? Math.min(100, Math.round((writer.totalImages / writer.totalContent) * 100))
|
||||
: 0,
|
||||
color: writerColors[1], // purple
|
||||
},
|
||||
{
|
||||
fromLabel: 'Review',
|
||||
fromValue: writer.contentReview,
|
||||
fromHref: '/writer/review',
|
||||
toLabel: 'Published',
|
||||
toValue: writer.contentPublished,
|
||||
toHref: '/writer/approved',
|
||||
progress: (writer.contentReview + writer.contentPublished) > 0
|
||||
? Math.round((writer.contentPublished / (writer.contentReview + writer.contentPublished)) * 100)
|
||||
: 0,
|
||||
color: writerColors[2], // success
|
||||
},
|
||||
];
|
||||
|
||||
const pipeline = module === 'planner' ? plannerPipeline : writerPipeline;
|
||||
const title = module === 'planner' ? 'Planner Module' : 'Writer Module';
|
||||
const links = module === 'planner'
|
||||
? [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Approved', href: '/writer/approved' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={`p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="text-sm text-gray-400">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Pipeline Rows */}
|
||||
<div className="space-y-4 mb-4">
|
||||
{pipeline.map((row, idx) => (
|
||||
<div key={idx}>
|
||||
{/* Row header: FromLabel Value ► ToLabel Value */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* From side */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Link
|
||||
to={row.fromHref}
|
||||
className="text-sm font-medium hover:underline truncate"
|
||||
style={{ color: row.color }}
|
||||
>
|
||||
{row.fromLabel}
|
||||
</Link>
|
||||
<span className="text-lg font-bold tabular-nums" style={{ color: row.color }}>
|
||||
{row.fromValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow icon */}
|
||||
<ChevronRightIcon
|
||||
className="w-5 h-5 flex-shrink-0 mx-2"
|
||||
style={{ color: row.color }}
|
||||
/>
|
||||
|
||||
{/* To side */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Link
|
||||
to={row.toHref}
|
||||
className="text-sm font-medium hover:underline truncate"
|
||||
style={{ color: row.color }}
|
||||
>
|
||||
{row.toLabel}
|
||||
</Link>
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{row.toValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: row.color,
|
||||
width: `${Math.min(100, Math.max(0, row.progress))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-wrap gap-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
{links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
to={link.href}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1 text-brand-500"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Credits Consumed Section - inline layout */}
|
||||
{module === 'planner' && credits.plannerTotal > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
<span className="text-xs font-semibold text-[color:var(--color-text-dim)] dark:text-gray-400 uppercase tracking-wide">
|
||||
Credits:
|
||||
</span>
|
||||
{credits.clusteringCredits > 0 && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Clustering{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-purple)' }}>{credits.clusteringCredits}</strong>
|
||||
</span>
|
||||
)}
|
||||
{credits.ideaGenerationCredits > 0 && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Ideas{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-warning)' }}>{credits.ideaGenerationCredits}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{module === 'writer' && credits.writerTotal > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
<span className="text-xs font-semibold text-[color:var(--color-text-dim)] dark:text-gray-400 uppercase tracking-wide">
|
||||
Credits:
|
||||
</span>
|
||||
{credits.contentGenerationCredits > 0 && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Content{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-success)' }}>{credits.contentGenerationCredits}</strong>
|
||||
</span>
|
||||
)}
|
||||
{credits.imageGenerationCredits > 0 && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Images{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-purple)' }}>{credits.imageGenerationCredits}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export { StandardizedModuleWidget };
|
||||
267
frontend/src/components/dashboard/WorkflowCompletionWidget.tsx
Normal file
267
frontend/src/components/dashboard/WorkflowCompletionWidget.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* WorkflowCompletionWidget - Standardized Workflow Stats Widget
|
||||
*
|
||||
* A centralized widget component that displays consistent workflow completion
|
||||
* stats across all Planner and Writer module pages.
|
||||
*
|
||||
* Features:
|
||||
* - Unified data fetching via useWorkflowStats hook
|
||||
* - Time-based filtering (Today, 7, 30, 90 days)
|
||||
* - Credits consumption display
|
||||
* - Consistent appearance across all pages
|
||||
*
|
||||
* IMPORTANT: Uses "Content Pages" not "Articles" per design requirements
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
import { useWorkflowStats, TimeFilter } from '../../hooks/useWorkflowStats';
|
||||
import { WORKFLOW_COLORS } from '../../config/colors.config';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
|
||||
|
||||
export interface WorkflowCompletionWidgetProps {
|
||||
/** Show credits consumption section */
|
||||
showCredits?: boolean;
|
||||
/** Link to analytics page */
|
||||
analyticsHref?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIME FILTER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TimeFilterProps {
|
||||
value: TimeFilter;
|
||||
onChange: (value: TimeFilter) => void;
|
||||
}
|
||||
|
||||
function TimeFilterButtons({ value, onChange }: TimeFilterProps) {
|
||||
const options: { value: TimeFilter; label: string }[] = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7', label: '7d' },
|
||||
{ value: '30', label: '30d' },
|
||||
{ value: '90', label: '90d' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded-md transition-all ${
|
||||
value === option.value
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPLETION ITEM COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface CompletionItemProps {
|
||||
label: string;
|
||||
value: number;
|
||||
barColor: string;
|
||||
maxValue: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
function CompletionItem({ label, value, barColor, maxValue, isLast }: CompletionItemProps) {
|
||||
const barWidth = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{/* Tree prefix */}
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-500 font-mono text-xs w-5 flex-shrink-0">
|
||||
{prefix}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-sm text-[color:var(--color-text)] dark:text-gray-300 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-16 h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: barColor,
|
||||
width: `${Math.min(100, barWidth)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white tabular-nums w-10 text-right flex-shrink-0">
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function WorkflowCompletionWidget({
|
||||
showCredits = true,
|
||||
analyticsHref = '/account/usage',
|
||||
className = '',
|
||||
}: WorkflowCompletionWidgetProps) {
|
||||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('30');
|
||||
const { planner, writer, credits, loading } = useWorkflowStats(timeFilter);
|
||||
|
||||
// Define planner items with unified colors from config
|
||||
// WORKFLOW_COLORS contains CSS variable names, so wrap with var()
|
||||
const plannerItems = [
|
||||
{ label: 'Keywords Clustered', value: planner.keywordsClustered, barColor: `var(${WORKFLOW_COLORS.planner.keywordsClustered})` },
|
||||
{ label: 'Clusters Created', value: planner.clustersCreated, barColor: `var(${WORKFLOW_COLORS.planner.clustersCreated})` },
|
||||
{ label: 'Ideas Generated', value: planner.ideasGenerated, barColor: `var(${WORKFLOW_COLORS.planner.ideasGenerated})` },
|
||||
];
|
||||
|
||||
// Define writer items - using "Content Pages" not "Articles"
|
||||
// Total content = drafts + review + published
|
||||
const totalContent = writer.contentDrafts + writer.contentReview + writer.contentPublished;
|
||||
const writerItems = [
|
||||
{ label: 'Content Pages', value: totalContent, barColor: `var(${WORKFLOW_COLORS.writer.contentPages})` },
|
||||
{ label: 'Images Created', value: writer.imagesCreated, barColor: `var(${WORKFLOW_COLORS.writer.imagesCreated})` },
|
||||
{ label: 'Pages Published', value: writer.contentPublished, barColor: `var(${WORKFLOW_COLORS.writer.pagesPublished})` },
|
||||
];
|
||||
|
||||
// Calculate max value for proportional bars (across both columns)
|
||||
const allValues = [...plannerItems, ...writerItems].map(i => i.value);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
|
||||
return (
|
||||
<Card className={`p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 ${className}`}>
|
||||
{/* Header with Time Filter */}
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide truncate">
|
||||
Workflow Completion
|
||||
</h3>
|
||||
<TimeFilterButtons value={timeFilter} onChange={setTimeFilter} />
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="text-sm text-gray-400">Loading stats...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Two-column layout: Planner | Writer */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{/* Planner Column */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-primary)' }}>
|
||||
Planner
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{plannerItems.map((item, idx) => (
|
||||
<CompletionItem
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
barColor={item.barColor}
|
||||
maxValue={maxValue}
|
||||
isLast={idx === plannerItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Writer Column */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-success)' }}>
|
||||
Writer
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{writerItems.map((item, idx) => (
|
||||
<CompletionItem
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
barColor={item.barColor}
|
||||
maxValue={maxValue}
|
||||
isLast={idx === writerItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits Used Section - Always show */}
|
||||
{showCredits && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Credits Used:{' '}
|
||||
<strong className="text-[color:var(--color-text)] dark:text-white font-bold">
|
||||
{credits.totalCreditsUsed.toLocaleString()}
|
||||
</strong>
|
||||
</span>
|
||||
{credits.plannerCreditsUsed > 0 && (
|
||||
<>
|
||||
<span className="text-[color:var(--color-stroke)] dark:text-gray-600">│</span>
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Planner:{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
{credits.plannerCreditsUsed.toLocaleString()}
|
||||
</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{credits.writerCreditsUsed > 0 && (
|
||||
<>
|
||||
<span className="text-[color:var(--color-stroke)] dark:text-gray-600">│</span>
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Writer:{' '}
|
||||
<strong className="font-bold" style={{ color: 'var(--color-success)' }}>
|
||||
{credits.writerCreditsUsed.toLocaleString()}
|
||||
</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Link */}
|
||||
{analyticsHref && (
|
||||
<div className="pt-3 mt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<Link
|
||||
to={analyticsHref}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1 text-brand-500"
|
||||
>
|
||||
View Full Analytics
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowCompletionWidget };
|
||||
@@ -33,14 +33,24 @@ interface WorkflowPipelineWidgetProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline stages with solid colored backgrounds matching Automation/Usage pages:
|
||||
* - Sites: brand/primary (blue)
|
||||
* - Keywords: brand/primary (blue)
|
||||
* - Clusters: purple
|
||||
* - Ideas: warning/amber
|
||||
* - Tasks: brand/primary (blue)
|
||||
* - Drafts/Content: success/green
|
||||
* - Published: success/green
|
||||
*/
|
||||
const stages = [
|
||||
{ key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', color: 'text-brand-600 dark:text-brand-400' },
|
||||
{ key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', color: 'text-brand-600 dark:text-brand-400' },
|
||||
{ key: 'clusters', label: 'Clusters', icon: GroupIcon, href: '/planner/clusters', color: 'text-purple-600 dark:text-purple-400' },
|
||||
{ key: 'ideas', label: 'Ideas', icon: BoltIcon, href: '/planner/ideas', color: 'text-warning-600 dark:text-warning-400' },
|
||||
{ key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', color: 'text-purple-600 dark:text-purple-400' },
|
||||
{ key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', color: 'text-success-600 dark:text-success-400' },
|
||||
{ key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', color: 'text-purple-600 dark:text-purple-400' },
|
||||
{ key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', gradient: 'from-brand-500 to-brand-600' },
|
||||
{ key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', gradient: 'from-brand-500 to-brand-600' },
|
||||
{ key: 'clusters', label: 'Clusters', icon: GroupIcon, href: '/planner/clusters', gradient: 'from-purple-500 to-purple-600' },
|
||||
{ key: 'ideas', label: 'Ideas', icon: BoltIcon, href: '/planner/ideas', gradient: 'from-warning-500 to-warning-600' },
|
||||
{ key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', gradient: 'from-brand-500 to-brand-600' },
|
||||
{ key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', gradient: 'from-success-500 to-success-600' },
|
||||
{ key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', gradient: 'from-success-500 to-success-600' },
|
||||
] as const;
|
||||
|
||||
// Small filled arrow triangle component
|
||||
@@ -79,13 +89,13 @@ export default function WorkflowPipelineWidget({ data, loading }: WorkflowPipeli
|
||||
to={stage.href}
|
||||
className="flex flex-col items-center group min-w-[60px]"
|
||||
>
|
||||
<div className="p-2.5 rounded-lg bg-gray-50 dark:bg-gray-800 group-hover:bg-brand-50 dark:group-hover:bg-brand-900/20 transition-colors border border-transparent group-hover:border-brand-200 dark:group-hover:border-brand-800">
|
||||
<Icon className={`w-6 h-6 ${stage.color}`} />
|
||||
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${stage.gradient} shadow-md group-hover:shadow-lg group-hover:scale-105 transition-all`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 mt-1.5 font-medium">
|
||||
{stage.label}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${stage.color}`}>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{loading ? '—' : typeof count === 'number' ? count.toLocaleString() : count}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
* Module Color Configuration
|
||||
* Single source of truth for module-specific colors throughout the UI
|
||||
*
|
||||
* DESIGN SYSTEM: All colors reference CSS variables from tokens.css
|
||||
* This ensures consistency with the design system.
|
||||
*
|
||||
* Based on section 4.5 of MASTER-IMPLEMENTATION-PLAN.md:
|
||||
* - Keywords: blue (primary)
|
||||
* - Clusters: purple
|
||||
* - Ideas: amber/warning (NOT purple!)
|
||||
* - Tasks: blue (primary)
|
||||
* - Content: green/success
|
||||
* - Images: purple
|
||||
* - Review: amber/warning
|
||||
* - Approved/Published: green/success
|
||||
*
|
||||
* These colors should be used consistently across:
|
||||
* - Module icons
|
||||
* - Progress bars
|
||||
@@ -10,123 +23,219 @@
|
||||
* - Chart segments
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS Variable names for colors - reference tokens.css
|
||||
* Use getCSSColor() to get the computed value at runtime
|
||||
*/
|
||||
export const CSS_VAR_COLORS = {
|
||||
primary: '--color-primary',
|
||||
primaryDark: '--color-primary-dark',
|
||||
success: '--color-success',
|
||||
successDark: '--color-success-dark',
|
||||
warning: '--color-warning',
|
||||
warningDark: '--color-warning-dark',
|
||||
danger: '--color-danger',
|
||||
dangerDark: '--color-danger-dark',
|
||||
purple: '--color-purple',
|
||||
purpleDark: '--color-purple-dark',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get computed CSS variable value at runtime
|
||||
*/
|
||||
export function getCSSColor(varName: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
|
||||
export const MODULE_COLORS = {
|
||||
// Planner Module
|
||||
keywords: {
|
||||
bg: 'bg-brand-500',
|
||||
bgLight: 'bg-brand-50',
|
||||
text: 'text-brand-600',
|
||||
textDark: 'text-brand-700',
|
||||
text: 'text-brand-500',
|
||||
textDark: 'text-brand-600',
|
||||
border: 'border-brand-500',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
hex: '#2C7AA1',
|
||||
cssVar: CSS_VAR_COLORS.primary,
|
||||
},
|
||||
clusters: {
|
||||
bg: 'bg-purple-500',
|
||||
bgLight: 'bg-purple-50',
|
||||
text: 'text-purple-600',
|
||||
textDark: 'text-purple-700',
|
||||
text: 'text-purple-500',
|
||||
textDark: 'text-purple-600',
|
||||
border: 'border-purple-500',
|
||||
gradient: 'from-purple-500 to-purple-600',
|
||||
hex: '#7c3aed',
|
||||
cssVar: CSS_VAR_COLORS.purple,
|
||||
},
|
||||
ideas: {
|
||||
bg: 'bg-purple-600',
|
||||
bgLight: 'bg-purple-50',
|
||||
text: 'text-purple-700',
|
||||
textDark: 'text-purple-800',
|
||||
border: 'border-purple-600',
|
||||
gradient: 'from-purple-600 to-purple-700',
|
||||
hex: '#6d28d9',
|
||||
bg: 'bg-warning-500',
|
||||
bgLight: 'bg-warning-50',
|
||||
text: 'text-warning-500',
|
||||
textDark: 'text-warning-600',
|
||||
border: 'border-warning-500',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
cssVar: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
|
||||
// Writer Module
|
||||
tasks: {
|
||||
bg: 'bg-success-600',
|
||||
bgLight: 'bg-success-50',
|
||||
text: 'text-success-600',
|
||||
textDark: 'text-success-700',
|
||||
border: 'border-success-600',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
hex: '#059669',
|
||||
bg: 'bg-brand-500',
|
||||
bgLight: 'bg-brand-50',
|
||||
text: 'text-brand-500',
|
||||
textDark: 'text-brand-600',
|
||||
border: 'border-brand-500',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
cssVar: CSS_VAR_COLORS.primary,
|
||||
},
|
||||
content: {
|
||||
bg: 'bg-success-500',
|
||||
bgLight: 'bg-success-50',
|
||||
text: 'text-success-600',
|
||||
textDark: 'text-success-700',
|
||||
text: 'text-success-500',
|
||||
textDark: 'text-success-600',
|
||||
border: 'border-success-500',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
hex: '#10b981',
|
||||
cssVar: CSS_VAR_COLORS.success,
|
||||
},
|
||||
images: {
|
||||
bg: 'bg-purple-500',
|
||||
bgLight: 'bg-purple-50',
|
||||
text: 'text-purple-600',
|
||||
textDark: 'text-purple-700',
|
||||
text: 'text-purple-500',
|
||||
textDark: 'text-purple-600',
|
||||
border: 'border-purple-500',
|
||||
gradient: 'from-purple-500 to-purple-600',
|
||||
hex: '#7c3aed',
|
||||
cssVar: CSS_VAR_COLORS.purple,
|
||||
},
|
||||
review: {
|
||||
bg: 'bg-warning-500',
|
||||
bgLight: 'bg-warning-50',
|
||||
text: 'text-warning-500',
|
||||
textDark: 'text-warning-600',
|
||||
border: 'border-warning-500',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
cssVar: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
approved: {
|
||||
bg: 'bg-success-500',
|
||||
bgLight: 'bg-success-50',
|
||||
text: 'text-success-500',
|
||||
textDark: 'text-success-600',
|
||||
border: 'border-success-500',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
cssVar: CSS_VAR_COLORS.success,
|
||||
},
|
||||
published: {
|
||||
bg: 'bg-success-500',
|
||||
bgLight: 'bg-success-50',
|
||||
text: 'text-success-500',
|
||||
textDark: 'text-success-600',
|
||||
border: 'border-success-500',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
cssVar: CSS_VAR_COLORS.success,
|
||||
},
|
||||
|
||||
// Automation
|
||||
// Automation & Dashboard
|
||||
automation: {
|
||||
bg: 'bg-brand-500',
|
||||
bgLight: 'bg-brand-50',
|
||||
text: 'text-brand-600',
|
||||
textDark: 'text-brand-700',
|
||||
text: 'text-brand-500',
|
||||
textDark: 'text-brand-600',
|
||||
border: 'border-brand-500',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
hex: '#2C7AA1',
|
||||
cssVar: CSS_VAR_COLORS.primary,
|
||||
},
|
||||
dashboard: {
|
||||
bg: 'bg-brand-500',
|
||||
bgLight: 'bg-brand-50',
|
||||
text: 'text-brand-500',
|
||||
textDark: 'text-brand-600',
|
||||
border: 'border-brand-500',
|
||||
gradient: 'from-brand-500 to-brand-600',
|
||||
cssVar: CSS_VAR_COLORS.primary,
|
||||
},
|
||||
|
||||
// Billing / Credits
|
||||
billing: {
|
||||
bg: 'bg-warning-500',
|
||||
bgLight: 'bg-warning-50',
|
||||
text: 'text-warning-600',
|
||||
textDark: 'text-warning-700',
|
||||
text: 'text-warning-500',
|
||||
textDark: 'text-warning-600',
|
||||
border: 'border-warning-500',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
hex: '#D9A12C',
|
||||
cssVar: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
credits: {
|
||||
bg: 'bg-warning-500',
|
||||
bgLight: 'bg-warning-50',
|
||||
text: 'text-warning-600',
|
||||
textDark: 'text-warning-700',
|
||||
text: 'text-warning-500',
|
||||
textDark: 'text-warning-600',
|
||||
border: 'border-warning-500',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
hex: '#D9A12C',
|
||||
cssVar: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
|
||||
// Status Colors
|
||||
success: {
|
||||
bg: 'bg-success-500',
|
||||
bgLight: 'bg-success-50',
|
||||
text: 'text-success-600',
|
||||
textDark: 'text-success-700',
|
||||
text: 'text-success-500',
|
||||
textDark: 'text-success-600',
|
||||
border: 'border-success-500',
|
||||
gradient: 'from-success-500 to-success-600',
|
||||
hex: '#10b981',
|
||||
cssVar: CSS_VAR_COLORS.success,
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-error-500',
|
||||
bgLight: 'bg-error-50',
|
||||
text: 'text-error-600',
|
||||
textDark: 'text-error-700',
|
||||
text: 'text-error-500',
|
||||
textDark: 'text-error-600',
|
||||
border: 'border-error-500',
|
||||
gradient: 'from-error-500 to-error-600',
|
||||
hex: '#ef4444',
|
||||
cssVar: CSS_VAR_COLORS.danger,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-warning-500',
|
||||
bgLight: 'bg-warning-50',
|
||||
text: 'text-warning-600',
|
||||
textDark: 'text-warning-700',
|
||||
text: 'text-warning-500',
|
||||
textDark: 'text-warning-600',
|
||||
border: 'border-warning-500',
|
||||
gradient: 'from-warning-500 to-warning-600',
|
||||
hex: '#D9A12C',
|
||||
cssVar: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Pipeline colors for module stats widgets.
|
||||
* Uses CSS variables for consistency with design system.
|
||||
* Call getCSSColor() to get the computed value at runtime.
|
||||
*/
|
||||
export const PIPELINE_COLORS = {
|
||||
// Planner: Keywords(primary) → Clusters(purple) → Ideas(warning)
|
||||
planner: [CSS_VAR_COLORS.primary, CSS_VAR_COLORS.purple, CSS_VAR_COLORS.warning],
|
||||
// Writer: Tasks(primary) → Content(purple) → Published(success)
|
||||
writer: [CSS_VAR_COLORS.primary, CSS_VAR_COLORS.purple, CSS_VAR_COLORS.success],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get pipeline colors as computed CSS values for inline styles
|
||||
*/
|
||||
export function getPipelineColors(module: 'planner' | 'writer'): string[] {
|
||||
return PIPELINE_COLORS[module].map(varName => getCSSColor(varName) || `var(${varName})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow completion widget colors - CSS variable names
|
||||
*/
|
||||
export const WORKFLOW_COLORS = {
|
||||
planner: {
|
||||
keywordsClustered: CSS_VAR_COLORS.primary,
|
||||
clustersCreated: CSS_VAR_COLORS.purple,
|
||||
ideasGenerated: CSS_VAR_COLORS.warning,
|
||||
},
|
||||
writer: {
|
||||
contentPages: CSS_VAR_COLORS.primary,
|
||||
imagesCreated: CSS_VAR_COLORS.purple,
|
||||
pagesPublished: CSS_VAR_COLORS.success,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -135,12 +244,11 @@ export const MODULE_COLORS = {
|
||||
* Ensures no more than 2 consecutive items share the same color family
|
||||
*/
|
||||
export const BALANCED_COLOR_SEQUENCE = [
|
||||
MODULE_COLORS.keywords, // blue
|
||||
MODULE_COLORS.keywords, // primary/blue
|
||||
MODULE_COLORS.clusters, // purple
|
||||
MODULE_COLORS.content, // green
|
||||
MODULE_COLORS.billing, // amber
|
||||
MODULE_COLORS.ideas, // purple (darker)
|
||||
MODULE_COLORS.tasks, // green (darker)
|
||||
MODULE_COLORS.content, // success/green
|
||||
MODULE_COLORS.ideas, // warning/amber
|
||||
MODULE_COLORS.images, // purple
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -158,5 +266,13 @@ export function getBalancedColor(index: number) {
|
||||
return BALANCED_COLOR_SEQUENCE[index % BALANCED_COLOR_SEQUENCE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSS variable value for a module color
|
||||
*/
|
||||
export function getModuleCSSColor(module: keyof typeof MODULE_COLORS): string {
|
||||
const color = MODULE_COLORS[module];
|
||||
return getCSSColor(color.cssVar) || `var(${color.cssVar})`;
|
||||
}
|
||||
|
||||
export type ModuleColorKey = keyof typeof MODULE_COLORS;
|
||||
export type ModuleColor = typeof MODULE_COLORS[ModuleColorKey];
|
||||
|
||||
252
frontend/src/hooks/useModuleStats.ts
Normal file
252
frontend/src/hooks/useModuleStats.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* useModuleStats Hook
|
||||
*
|
||||
* Centralized hook for fetching module-level statistics
|
||||
* used in the Module Stats widget (widget 2) of the footer.
|
||||
*
|
||||
* IMPORTANT: Content table structure
|
||||
* - Tasks is separate table (has status: queued, processing, completed, failed)
|
||||
* - Content table has status field: 'draft', 'review', 'published' (approved)
|
||||
* - Images is separate table linked to content
|
||||
*
|
||||
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
|
||||
* which returns by_operation with operation types:
|
||||
* - clustering: Keyword Clustering (Planner)
|
||||
* - idea_generation: Content Ideas Generation (Planner)
|
||||
* - content_generation: Content Generation (Writer)
|
||||
* - image_generation: Image Generation (Writer)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
fetchAPI,
|
||||
} from '../services/api';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
import { useSectorStore } from '../store/sectorStore';
|
||||
|
||||
export interface PlannerModuleStats {
|
||||
totalKeywords: number;
|
||||
keywordsMapped: number; // Keywords with status='mapped' (assigned to clusters)
|
||||
totalClusters: number;
|
||||
totalIdeas: number;
|
||||
ideasConverted: number; // Ideas that have been converted to tasks
|
||||
}
|
||||
|
||||
export interface WriterModuleStats {
|
||||
totalTasks: number;
|
||||
tasksCompleted: number; // Tasks with status='completed'
|
||||
contentDrafts: number; // Content with status='draft'
|
||||
contentReview: number; // Content with status='review'
|
||||
contentPublished: number; // Content with status='published' (approved)
|
||||
totalContent: number; // All content regardless of status
|
||||
totalImages: number;
|
||||
}
|
||||
|
||||
export interface ModuleCredits {
|
||||
// Planner AI function credits
|
||||
clusteringCredits: number;
|
||||
ideaGenerationCredits: number;
|
||||
plannerTotal: number;
|
||||
// Writer AI function credits
|
||||
contentGenerationCredits: number;
|
||||
imageGenerationCredits: number;
|
||||
writerTotal: number;
|
||||
}
|
||||
|
||||
export interface ModuleStatsData {
|
||||
planner: PlannerModuleStats;
|
||||
writer: WriterModuleStats;
|
||||
credits: ModuleCredits;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const defaultStats: ModuleStatsData = {
|
||||
planner: {
|
||||
totalKeywords: 0,
|
||||
keywordsMapped: 0,
|
||||
totalClusters: 0,
|
||||
totalIdeas: 0,
|
||||
ideasConverted: 0,
|
||||
},
|
||||
writer: {
|
||||
totalTasks: 0,
|
||||
tasksCompleted: 0,
|
||||
contentDrafts: 0,
|
||||
contentReview: 0,
|
||||
contentPublished: 0,
|
||||
totalContent: 0,
|
||||
totalImages: 0,
|
||||
},
|
||||
credits: {
|
||||
clusteringCredits: 0,
|
||||
ideaGenerationCredits: 0,
|
||||
plannerTotal: 0,
|
||||
contentGenerationCredits: 0,
|
||||
imageGenerationCredits: 0,
|
||||
writerTotal: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useModuleStats() {
|
||||
const [stats, setStats] = useState<ModuleStatsData>(defaultStats);
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
// Don't load if no active site - wait for site to be set
|
||||
if (!activeSite?.id) {
|
||||
setStats(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setStats(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
// Build common filters with explicit site/sector IDs
|
||||
const baseFilters = {
|
||||
page_size: 1,
|
||||
site_id: activeSite.id,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch all stats in parallel for performance
|
||||
const [
|
||||
// Planner stats
|
||||
keywordsRes,
|
||||
keywordsMappedRes,
|
||||
clustersRes,
|
||||
ideasRes,
|
||||
ideasQueuedRes,
|
||||
ideasCompletedRes,
|
||||
// Writer stats
|
||||
tasksRes,
|
||||
tasksCompletedRes,
|
||||
contentDraftsRes,
|
||||
contentReviewRes,
|
||||
contentPublishedRes,
|
||||
contentTotalRes,
|
||||
imagesRes,
|
||||
// Credits from billing summary
|
||||
creditsRes,
|
||||
] = await Promise.all([
|
||||
// Total keywords
|
||||
fetchKeywords({ ...baseFilters }),
|
||||
// Keywords that are mapped to clusters
|
||||
fetchKeywords({ ...baseFilters, status: 'mapped' }),
|
||||
// Total clusters
|
||||
fetchClusters({ ...baseFilters }),
|
||||
// Total ideas
|
||||
fetchContentIdeas({ ...baseFilters }),
|
||||
// Ideas that are queued (converted to tasks, waiting)
|
||||
fetchContentIdeas({ ...baseFilters, status: 'queued' }),
|
||||
// Ideas that are completed (converted to tasks, done)
|
||||
fetchContentIdeas({ ...baseFilters, status: 'completed' }),
|
||||
// Total tasks
|
||||
fetchTasks({ ...baseFilters }),
|
||||
// Completed tasks
|
||||
fetchTasks({ ...baseFilters, status: 'completed' }),
|
||||
// Content with status='draft'
|
||||
fetchContent({ ...baseFilters, status: 'draft' }),
|
||||
// Content with status='review'
|
||||
fetchContent({ ...baseFilters, status: 'review' }),
|
||||
// Content with status='published' (approved)
|
||||
fetchContent({ ...baseFilters, status: 'published' }),
|
||||
// Total content (all statuses)
|
||||
fetchContent({ ...baseFilters }),
|
||||
// Total images
|
||||
fetchImages({ ...baseFilters }),
|
||||
// Credits usage from billing summary
|
||||
fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
|
||||
data: { by_operation: {} }
|
||||
})),
|
||||
]);
|
||||
|
||||
// Parse credits response
|
||||
const creditsData = creditsRes?.data || creditsRes || {};
|
||||
const byOperation = creditsData.by_operation || {};
|
||||
|
||||
// Extract credits by operation type
|
||||
const clusteringCredits = byOperation.clustering?.credits || 0;
|
||||
const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
|
||||
const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
|
||||
const imageCredits = (byOperation.image_generation?.credits || 0) +
|
||||
(byOperation.images?.credits || 0) +
|
||||
(byOperation.image_prompt_extraction?.credits || 0);
|
||||
|
||||
// Ideas converted = queued + completed (ideas that have tasks created)
|
||||
const ideasConverted = (ideasQueuedRes?.count || 0) + (ideasCompletedRes?.count || 0);
|
||||
|
||||
setStats({
|
||||
planner: {
|
||||
totalKeywords: keywordsRes?.count || 0,
|
||||
keywordsMapped: keywordsMappedRes?.count || 0,
|
||||
totalClusters: clustersRes?.count || 0,
|
||||
totalIdeas: ideasRes?.count || 0,
|
||||
ideasConverted: ideasConverted,
|
||||
},
|
||||
writer: {
|
||||
totalTasks: tasksRes?.count || 0,
|
||||
tasksCompleted: tasksCompletedRes?.count || 0,
|
||||
contentDrafts: contentDraftsRes?.count || 0,
|
||||
contentReview: contentReviewRes?.count || 0,
|
||||
contentPublished: contentPublishedRes?.count || 0,
|
||||
totalContent: contentTotalRes?.count || 0,
|
||||
totalImages: imagesRes?.count || 0,
|
||||
},
|
||||
credits: {
|
||||
clusteringCredits,
|
||||
ideaGenerationCredits: ideaCredits,
|
||||
plannerTotal: clusteringCredits + ideaCredits,
|
||||
contentGenerationCredits: contentCredits,
|
||||
imageGenerationCredits: imageCredits,
|
||||
writerTotal: contentCredits + imageCredits,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading module stats:', error);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error.message || 'Failed to load module stats',
|
||||
}));
|
||||
}
|
||||
}, [activeSite?.id, activeSector?.id]);
|
||||
|
||||
// Load stats on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Listen for data changes to refresh
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => loadStats();
|
||||
|
||||
window.addEventListener('site-changed', handleRefresh);
|
||||
window.addEventListener('sector-changed', handleRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('site-changed', handleRefresh);
|
||||
window.removeEventListener('sector-changed', handleRefresh);
|
||||
};
|
||||
}, [loadStats]);
|
||||
|
||||
// Expose refresh function
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
return { ...stats, refresh };
|
||||
}
|
||||
|
||||
export default useModuleStats;
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* useThreeWidgetFooter - Hook to build ThreeWidgetFooter props
|
||||
*
|
||||
* Provides helper functions to construct the three widgets:
|
||||
* - Page Progress (current page metrics)
|
||||
* - Module Stats (workflow pipeline)
|
||||
* - Completion Stats (both modules summary)
|
||||
*
|
||||
* Usage:
|
||||
* const footerProps = useThreeWidgetFooter({
|
||||
* module: 'planner',
|
||||
* currentPage: 'keywords',
|
||||
* plannerData: { keywords: [...], clusters: [...] },
|
||||
* completionData: { ... }
|
||||
* });
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
ThreeWidgetFooterProps,
|
||||
PageProgressWidget,
|
||||
ModuleStatsWidget,
|
||||
CompletionWidget,
|
||||
SubmoduleColor,
|
||||
} from '../components/dashboard/ThreeWidgetFooter';
|
||||
|
||||
// ============================================================================
|
||||
// DATA INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
interface PlannerPageData {
|
||||
keywords?: Array<{ cluster_id?: number | null; volume?: number }>;
|
||||
clusters?: Array<{ ideas_count?: number; keywords_count?: number }>;
|
||||
ideas?: Array<{ status?: string }>;
|
||||
totalKeywords?: number;
|
||||
totalClusters?: number;
|
||||
totalIdeas?: number;
|
||||
}
|
||||
|
||||
interface WriterPageData {
|
||||
tasks?: Array<{ status?: string }>;
|
||||
content?: Array<{ status?: string; has_generated_images?: boolean }>;
|
||||
totalTasks?: number;
|
||||
totalContent?: number;
|
||||
totalPublished?: number;
|
||||
}
|
||||
|
||||
interface CompletionData {
|
||||
keywordsClustered?: number;
|
||||
clustersCreated?: number;
|
||||
ideasGenerated?: number;
|
||||
contentGenerated?: number;
|
||||
imagesCreated?: number;
|
||||
articlesPublished?: number;
|
||||
creditsUsed?: number;
|
||||
totalOperations?: number;
|
||||
}
|
||||
|
||||
interface UseThreeWidgetFooterOptions {
|
||||
module: 'planner' | 'writer';
|
||||
currentPage: 'keywords' | 'clusters' | 'ideas' | 'tasks' | 'content' | 'images' | 'review' | 'published';
|
||||
plannerData?: PlannerPageData;
|
||||
writerData?: WriterPageData;
|
||||
completionData?: CompletionData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLANNER PAGE PROGRESS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildKeywordsPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const keywords = data.keywords || [];
|
||||
const totalKeywords = data.totalKeywords || keywords.length;
|
||||
const clusteredCount = keywords.filter(k => k.cluster_id).length;
|
||||
const unmappedCount = keywords.filter(k => !k.cluster_id).length;
|
||||
const totalVolume = keywords.reduce((sum, k) => sum + (k.volume || 0), 0);
|
||||
const clusteredPercent = totalKeywords > 0 ? Math.round((clusteredCount / totalKeywords) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Keywords', value: totalKeywords },
|
||||
{ label: 'Clustered', value: clusteredCount, percentage: `${clusteredPercent}%` },
|
||||
{ label: 'Unmapped', value: unmappedCount },
|
||||
{ label: 'Volume', value: totalVolume >= 1000 ? `${(totalVolume / 1000).toFixed(1)}K` : totalVolume },
|
||||
],
|
||||
progress: {
|
||||
value: clusteredPercent,
|
||||
label: `${clusteredPercent}% Clustered`,
|
||||
color: clusteredPercent >= 80 ? 'green' : 'blue',
|
||||
},
|
||||
hint: unmappedCount > 0 ? `${unmappedCount} keywords ready to cluster` : 'All keywords clustered!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildClustersPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const clusters = data.clusters || [];
|
||||
const totalClusters = data.totalClusters || clusters.length;
|
||||
const withIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
|
||||
const totalKeywords = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
|
||||
const readyClusters = clusters.filter(c => (c.ideas_count || 0) === 0).length;
|
||||
const ideasPercent = totalClusters > 0 ? Math.round((withIdeas / totalClusters) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Clusters', value: totalClusters },
|
||||
{ label: 'With Ideas', value: withIdeas, percentage: `${ideasPercent}%` },
|
||||
{ label: 'Keywords', value: totalKeywords },
|
||||
{ label: 'Ready', value: readyClusters },
|
||||
],
|
||||
progress: {
|
||||
value: ideasPercent,
|
||||
label: `${ideasPercent}% Have Ideas`,
|
||||
color: ideasPercent >= 70 ? 'green' : 'blue',
|
||||
},
|
||||
hint: readyClusters > 0 ? `${readyClusters} clusters ready for idea generation` : 'All clusters have ideas!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildIdeasPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const ideas = data.ideas || [];
|
||||
const totalIdeas = data.totalIdeas || ideas.length;
|
||||
const inTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
|
||||
const pending = ideas.filter(i => i.status === 'new').length;
|
||||
const convertedPercent = totalIdeas > 0 ? Math.round((inTasks / totalIdeas) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Ideas', value: totalIdeas },
|
||||
{ label: 'In Tasks', value: inTasks, percentage: `${convertedPercent}%` },
|
||||
{ label: 'Pending', value: pending },
|
||||
{ label: 'From Clusters', value: data.totalClusters || 0 },
|
||||
],
|
||||
progress: {
|
||||
value: convertedPercent,
|
||||
label: `${convertedPercent}% Converted`,
|
||||
color: convertedPercent >= 60 ? 'green' : 'blue',
|
||||
},
|
||||
hint: pending > 0 ? `${pending} ideas ready to become tasks` : 'All ideas converted!',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WRITER PAGE PROGRESS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildTasksPageProgress(data: WriterPageData): PageProgressWidget {
|
||||
const tasks = data.tasks || [];
|
||||
const total = data.totalTasks || tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const queue = tasks.filter(t => t.status === 'queued').length;
|
||||
const processing = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const completedPercent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Total', value: total },
|
||||
{ label: 'Complete', value: completed, percentage: `${completedPercent}%` },
|
||||
{ label: 'Queue', value: queue },
|
||||
{ label: 'Processing', value: processing },
|
||||
],
|
||||
progress: {
|
||||
value: completedPercent,
|
||||
label: `${completedPercent}% Generated`,
|
||||
color: completedPercent >= 60 ? 'green' : 'blue',
|
||||
},
|
||||
hint: queue > 0 ? `${queue} tasks in queue for content generation` : 'All tasks processed!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildContentPageProgress(data: WriterPageData): PageProgressWidget {
|
||||
const content = data.content || [];
|
||||
const drafts = content.filter(c => c.status === 'draft').length;
|
||||
const hasImages = content.filter(c => c.has_generated_images).length;
|
||||
const ready = content.filter(c => c.status === 'review' || c.status === 'published').length;
|
||||
const imagesPercent = drafts > 0 ? Math.round((hasImages / drafts) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Drafts', value: drafts },
|
||||
{ label: 'Has Images', value: hasImages, percentage: `${imagesPercent}%` },
|
||||
{ label: 'Total Words', value: '—' }, // Would need word count from API
|
||||
{ label: 'Ready', value: ready },
|
||||
],
|
||||
progress: {
|
||||
value: imagesPercent,
|
||||
label: `${imagesPercent}% Have Images`,
|
||||
color: imagesPercent >= 70 ? 'green' : 'blue',
|
||||
},
|
||||
hint: drafts - hasImages > 0 ? `${drafts - hasImages} drafts need images before review` : 'All drafts have images!',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MODULE STATS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildPlannerModuleStats(data: PlannerPageData): ModuleStatsWidget {
|
||||
const keywords = data.keywords || [];
|
||||
const clusters = data.clusters || [];
|
||||
const ideas = data.ideas || [];
|
||||
|
||||
const totalKeywords = data.totalKeywords || keywords.length;
|
||||
const totalClusters = data.totalClusters || clusters.length;
|
||||
const totalIdeas = data.totalIdeas || ideas.length;
|
||||
const clusteredKeywords = keywords.filter(k => k.cluster_id).length;
|
||||
const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
|
||||
const ideasInTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
|
||||
|
||||
return {
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: totalKeywords,
|
||||
toLabel: 'Clusters',
|
||||
toValue: totalClusters,
|
||||
actionLabel: 'Auto Cluster',
|
||||
progress: totalKeywords > 0 ? Math.round((clusteredKeywords / totalKeywords) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: totalClusters,
|
||||
toLabel: 'Ideas',
|
||||
toValue: totalIdeas,
|
||||
actionLabel: 'Generate Ideas',
|
||||
progress: totalClusters > 0 ? Math.round((clustersWithIdeas / totalClusters) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: totalIdeas,
|
||||
toLabel: 'Tasks',
|
||||
toValue: ideasInTasks,
|
||||
actionLabel: 'Create Tasks',
|
||||
progress: totalIdeas > 0 ? Math.round((ideasInTasks / totalIdeas) * 100) : 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildWriterModuleStats(data: WriterPageData): ModuleStatsWidget {
|
||||
const tasks = data.tasks || [];
|
||||
const content = data.content || [];
|
||||
|
||||
const totalTasks = data.totalTasks || tasks.length;
|
||||
const completedTasks = tasks.filter(t => t.status === 'completed').length;
|
||||
const drafts = content.filter(c => c.status === 'draft').length;
|
||||
const withImages = content.filter(c => c.has_generated_images).length;
|
||||
const ready = content.filter(c => c.status === 'review').length;
|
||||
const published = data.totalPublished || content.filter(c => c.status === 'published').length;
|
||||
|
||||
return {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalTasks,
|
||||
toLabel: 'Drafts',
|
||||
toValue: drafts,
|
||||
actionLabel: 'Generate Content',
|
||||
progress: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: drafts,
|
||||
toLabel: 'Images',
|
||||
toValue: withImages,
|
||||
actionLabel: 'Generate Images',
|
||||
progress: drafts > 0 ? Math.round((withImages / drafts) * 100) : 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: ready,
|
||||
toLabel: 'Published',
|
||||
toValue: published,
|
||||
actionLabel: 'Review & Publish',
|
||||
progress: ready > 0 ? Math.round((published / (ready + published)) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPLETION STATS BUILDER
|
||||
// ============================================================================
|
||||
|
||||
function buildCompletionStats(data: CompletionData): CompletionWidget {
|
||||
return {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: data.keywordsClustered || 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: data.clustersCreated || 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: data.ideasGenerated || 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: data.contentGenerated || 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: data.imagesCreated || 0, color: 'purple' },
|
||||
{ label: 'Articles Published', value: data.articlesPublished || 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: data.creditsUsed,
|
||||
operationsCount: data.totalOperations,
|
||||
analyticsHref: '/account/usage',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN HOOK
|
||||
// ============================================================================
|
||||
|
||||
export function useThreeWidgetFooter(options: UseThreeWidgetFooterOptions): ThreeWidgetFooterProps {
|
||||
const { module, currentPage, plannerData = {}, writerData = {}, completionData = {} } = options;
|
||||
|
||||
return useMemo(() => {
|
||||
// Build page progress based on current page
|
||||
let pageProgress: PageProgressWidget;
|
||||
|
||||
if (module === 'planner') {
|
||||
switch (currentPage) {
|
||||
case 'keywords':
|
||||
pageProgress = buildKeywordsPageProgress(plannerData);
|
||||
break;
|
||||
case 'clusters':
|
||||
pageProgress = buildClustersPageProgress(plannerData);
|
||||
break;
|
||||
case 'ideas':
|
||||
pageProgress = buildIdeasPageProgress(plannerData);
|
||||
break;
|
||||
default:
|
||||
pageProgress = buildKeywordsPageProgress(plannerData);
|
||||
}
|
||||
} else {
|
||||
switch (currentPage) {
|
||||
case 'tasks':
|
||||
pageProgress = buildTasksPageProgress(writerData);
|
||||
break;
|
||||
case 'content':
|
||||
case 'images':
|
||||
case 'review':
|
||||
pageProgress = buildContentPageProgress(writerData);
|
||||
break;
|
||||
default:
|
||||
pageProgress = buildTasksPageProgress(writerData);
|
||||
}
|
||||
}
|
||||
|
||||
// Build module stats
|
||||
const moduleStats = module === 'planner'
|
||||
? buildPlannerModuleStats(plannerData)
|
||||
: buildWriterModuleStats(writerData);
|
||||
|
||||
// Build completion stats
|
||||
const completion = buildCompletionStats(completionData);
|
||||
|
||||
// Determine submodule color based on current page
|
||||
let submoduleColor: SubmoduleColor = 'blue';
|
||||
if (currentPage === 'clusters') submoduleColor = 'green';
|
||||
if (currentPage === 'ideas') submoduleColor = 'amber';
|
||||
if (currentPage === 'images') submoduleColor = 'purple';
|
||||
|
||||
return {
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
submoduleColor,
|
||||
};
|
||||
}, [module, currentPage, plannerData, writerData, completionData]);
|
||||
}
|
||||
|
||||
export default useThreeWidgetFooter;
|
||||
294
frontend/src/hooks/useWorkflowStats.ts
Normal file
294
frontend/src/hooks/useWorkflowStats.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* useWorkflowStats Hook
|
||||
*
|
||||
* Centralized hook for fetching workflow statistics across
|
||||
* Planner and Writer modules with time-based filtering.
|
||||
*
|
||||
* This provides consistent data for the WorkflowCompletionWidget
|
||||
* across all pages.
|
||||
*
|
||||
* IMPORTANT: Content table structure
|
||||
* - Tasks is separate table
|
||||
* - Content table has status field: 'draft', 'review', 'published' (approved)
|
||||
* - Images is separate table linked to content
|
||||
*
|
||||
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
|
||||
* which returns by_operation with operation types:
|
||||
* - clustering: Keyword Clustering
|
||||
* - idea_generation: Content Ideas Generation
|
||||
* - content_generation: Content Generation
|
||||
* - image_generation: Image Generation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
fetchAPI,
|
||||
} from '../services/api';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
import { useSectorStore } from '../store/sectorStore';
|
||||
|
||||
// Time filter options (in days)
|
||||
export type TimeFilter = 'today' | '7' | '30' | '90' | 'all';
|
||||
|
||||
export interface CreditsBreakdown {
|
||||
clustering: number;
|
||||
ideaGeneration: number;
|
||||
contentGeneration: number;
|
||||
imageGeneration: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WorkflowStats {
|
||||
// Planner Module Stats
|
||||
planner: {
|
||||
keywordsClustered: number;
|
||||
totalKeywords: number;
|
||||
clustersCreated: number;
|
||||
ideasGenerated: number;
|
||||
};
|
||||
// Writer Module Stats
|
||||
writer: {
|
||||
tasksTotal: number;
|
||||
contentDrafts: number; // Content with status='draft'
|
||||
contentReview: number; // Content with status='review'
|
||||
contentPublished: number; // Content with status='published' (approved)
|
||||
imagesCreated: number;
|
||||
};
|
||||
// Credit consumption stats - detailed breakdown by operation
|
||||
credits: {
|
||||
// Planner module credits
|
||||
plannerCreditsUsed: number; // clustering + idea_generation
|
||||
clusteringCredits: number; // Just clustering
|
||||
ideaGenerationCredits: number; // Just idea generation
|
||||
// Writer module credits
|
||||
writerCreditsUsed: number; // content_generation + image_generation
|
||||
contentGenerationCredits: number;
|
||||
imageGenerationCredits: number;
|
||||
// Total
|
||||
totalCreditsUsed: number;
|
||||
};
|
||||
// Loading state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const defaultStats: WorkflowStats = {
|
||||
planner: {
|
||||
keywordsClustered: 0,
|
||||
totalKeywords: 0,
|
||||
clustersCreated: 0,
|
||||
ideasGenerated: 0,
|
||||
},
|
||||
writer: {
|
||||
tasksTotal: 0,
|
||||
contentDrafts: 0,
|
||||
contentReview: 0,
|
||||
contentPublished: 0,
|
||||
imagesCreated: 0,
|
||||
},
|
||||
credits: {
|
||||
plannerCreditsUsed: 0,
|
||||
clusteringCredits: 0,
|
||||
ideaGenerationCredits: 0,
|
||||
writerCreditsUsed: 0,
|
||||
contentGenerationCredits: 0,
|
||||
imageGenerationCredits: 0,
|
||||
totalCreditsUsed: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Calculate the date filter based on time range
|
||||
function getDateFilter(timeFilter: TimeFilter): string | undefined {
|
||||
if (timeFilter === 'all') return undefined;
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (timeFilter) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
break;
|
||||
case '7':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '30':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '90':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return startDate.toISOString();
|
||||
}
|
||||
|
||||
export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
|
||||
const [stats, setStats] = useState<WorkflowStats>(defaultStats);
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
// Don't load if no active site - wait for site to be set
|
||||
if (!activeSite?.id) {
|
||||
setStats(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setStats(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Build date filter query param
|
||||
const dateFilter = getDateFilter(timeFilter);
|
||||
const dateParam = dateFilter ? `&created_at__gte=${dateFilter.split('T')[0]}` : '';
|
||||
|
||||
// Build site/sector params for direct API calls
|
||||
const siteParam = `&site_id=${activeSite.id}`;
|
||||
const sectorParam = activeSector?.id ? `§or_id=${activeSector.id}` : '';
|
||||
const baseParams = `${siteParam}${sectorParam}`;
|
||||
|
||||
// Build common filters for fetch* functions
|
||||
const baseFilters = {
|
||||
page_size: 1,
|
||||
site_id: activeSite.id,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
};
|
||||
|
||||
// Fetch all stats in parallel for performance
|
||||
// Note: page_size=1 is used to just get the count, not actual data
|
||||
const [
|
||||
// Planner stats - these APIs support created_at__gte filter
|
||||
keywordsRes,
|
||||
keywordsClusteredRes,
|
||||
clustersRes,
|
||||
ideasRes,
|
||||
// Writer stats
|
||||
tasksRes,
|
||||
contentDraftRes,
|
||||
contentReviewRes,
|
||||
contentPublishedRes,
|
||||
imagesRes,
|
||||
// Credits stats from billing summary endpoint
|
||||
creditsRes,
|
||||
] = await Promise.all([
|
||||
// Total keywords (with date filter via direct API call)
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/keywords/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchKeywords({ ...baseFilters }),
|
||||
// Keywords that are clustered (status='mapped')
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/keywords/?page_size=1&status=mapped${baseParams}${dateParam}`)
|
||||
: fetchKeywords({ ...baseFilters, status: 'mapped' }),
|
||||
// Total clusters
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/clusters/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchClusters({ ...baseFilters }),
|
||||
// Total ideas
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/ideas/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchContentIdeas({ ...baseFilters }),
|
||||
// Total tasks
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/tasks/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchTasks({ ...baseFilters }),
|
||||
// Content with status='draft'
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=draft${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'draft' }),
|
||||
// Content with status='review'
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=review${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'review' }),
|
||||
// Content with status='published' (approved)
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=published${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'published' }),
|
||||
// Total images
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/images/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchImages({ ...baseFilters }),
|
||||
// Credits usage from billing summary endpoint - includes by_operation breakdown
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/billing/credits/usage/summary/?start_date=${dateFilter}`)
|
||||
: fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
|
||||
data: { total_credits_used: 0, by_operation: {} }
|
||||
})),
|
||||
]);
|
||||
|
||||
// Parse credits response - extract by_operation data
|
||||
const creditsData = creditsRes?.data || creditsRes || {};
|
||||
const byOperation = creditsData.by_operation || {};
|
||||
|
||||
// Extract credits by operation type
|
||||
// Planner operations: clustering, idea_generation (also 'ideas' legacy)
|
||||
const clusteringCredits = byOperation.clustering?.credits || 0;
|
||||
const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
|
||||
|
||||
// Writer operations: content_generation, image_generation (also 'content', 'images' legacy)
|
||||
const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
|
||||
const imageCredits = (byOperation.image_generation?.credits || 0) +
|
||||
(byOperation.images?.credits || 0) +
|
||||
(byOperation.image_prompt_extraction?.credits || 0);
|
||||
|
||||
const plannerTotal = clusteringCredits + ideaCredits;
|
||||
const writerTotal = contentCredits + imageCredits;
|
||||
|
||||
setStats({
|
||||
planner: {
|
||||
totalKeywords: keywordsRes?.count || 0,
|
||||
keywordsClustered: keywordsClusteredRes?.count || 0,
|
||||
clustersCreated: clustersRes?.count || 0,
|
||||
ideasGenerated: ideasRes?.count || 0,
|
||||
},
|
||||
writer: {
|
||||
tasksTotal: tasksRes?.count || 0,
|
||||
contentDrafts: contentDraftRes?.count || 0,
|
||||
contentReview: contentReviewRes?.count || 0,
|
||||
contentPublished: contentPublishedRes?.count || 0,
|
||||
imagesCreated: imagesRes?.count || 0,
|
||||
},
|
||||
credits: {
|
||||
plannerCreditsUsed: plannerTotal,
|
||||
clusteringCredits: clusteringCredits,
|
||||
ideaGenerationCredits: ideaCredits,
|
||||
writerCreditsUsed: writerTotal,
|
||||
contentGenerationCredits: contentCredits,
|
||||
imageGenerationCredits: imageCredits,
|
||||
totalCreditsUsed: creditsData.total_credits_used || plannerTotal + writerTotal,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading workflow stats:', error);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error.message || 'Failed to load workflow stats',
|
||||
}));
|
||||
}
|
||||
}, [activeSite?.id, activeSector?.id, timeFilter]);
|
||||
|
||||
// Load stats on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Expose refresh function
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
return { ...stats, refresh };
|
||||
}
|
||||
|
||||
export default useWorkflowStats;
|
||||
@@ -271,3 +271,301 @@
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.chat-height { height: calc(100vh - 8.125rem); }
|
||||
.inbox-height { height: calc(100vh - 8.125rem); }
|
||||
|
||||
/* ===================================================================
|
||||
IGNY8 ACTIVE UTILITY CLASSES
|
||||
Migrated from igny8-colors.css - these are actively used in components
|
||||
=================================================================== */
|
||||
|
||||
/* === Styled Dropdown/Select === */
|
||||
.igny8-select-styled {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 12px center !important;
|
||||
padding-right: 36px !important;
|
||||
}
|
||||
|
||||
.dark .igny8-select-styled {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
/* === Header Metrics Bar (compact, right-aligned) === */
|
||||
.igny8-header-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .igny8-header-metrics {
|
||||
background: transparent;
|
||||
box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.igny8-header-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.igny8-header-metric-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgb(203 213 225);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-separator {
|
||||
background: rgb(148 163 184);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.igny8-header-metric-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-label {
|
||||
color: rgb(148 163 184);
|
||||
}
|
||||
|
||||
.igny8-header-metric-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: rgb(30 41 59);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.igny8-header-metric-value-credits {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-value {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.blue {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.green {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.amber {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.purple {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
|
||||
/* === Table Compact Styles === */
|
||||
.igny8-table-compact th {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--color-gray-600) !important;
|
||||
text-align: left !important;
|
||||
background-color: var(--color-gray-50) !important;
|
||||
border-bottom: 2px solid var(--color-gray-200) !important;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.dark .igny8-table-compact th {
|
||||
color: var(--color-gray-200) !important;
|
||||
background-color: rgba(15, 23, 42, 0.5) !important;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.igny8-table-compact td {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 14px !important;
|
||||
border-bottom: 1px solid var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
.dark .igny8-table-compact td {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.igny8-table-compact th.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.igny8-table-compact td.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* === Smooth Table Height Transitions === */
|
||||
.igny8-table-container {
|
||||
min-height: 500px;
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
will-change: min-height;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading {
|
||||
min-height: 500px;
|
||||
overflow: hidden !important;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: fadeInContainer 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInContainer {
|
||||
from { opacity: 0.95; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.igny8-table-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
|
||||
transition: opacity 0.4s ease-in-out;
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-wrapper {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-wrapper::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
animation: showScrollbar 0.4s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes showScrollbar {
|
||||
from { scrollbar-width: none; }
|
||||
to { scrollbar-width: thin; }
|
||||
}
|
||||
|
||||
.igny8-table-smooth {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
min-width: 100%;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-smooth {
|
||||
opacity: 0.8;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-smooth {
|
||||
opacity: 1;
|
||||
table-layout: auto;
|
||||
transition: opacity 0.5s ease-in-out, table-layout 0.1s ease-out;
|
||||
}
|
||||
|
||||
.igny8-table-body {
|
||||
position: relative;
|
||||
min-height: 450px;
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-body {
|
||||
min-height: 450px;
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-body {
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-body > tr:not(.igny8-skeleton-row) {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-body > tr.igny8-skeleton-row {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.igny8-data-row {
|
||||
animation: fadeInRow 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
@keyframes fadeInRow {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.igny8-skeleton-row {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
display: table-row !important;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading * {
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* === Filter Bar === */
|
||||
.igny8-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* === Difficulty Badge Special Styling === */
|
||||
.difficulty-badge {
|
||||
border-radius: 3px !important;
|
||||
min-width: 28px !important;
|
||||
display: inline-flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.difficulty-badge.difficulty-very-hard {
|
||||
background-color: var(--color-error-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.dark .difficulty-badge.difficulty-very-hard {
|
||||
background-color: var(--color-error-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./styles/igny8-colors.css"; /* IGNY8 custom colors - separate from TailAdmin */
|
||||
import "swiper/swiper-bundle.css";
|
||||
import "flatpickr/dist/flatpickr.css";
|
||||
import App from "./App";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import MarketingApp from "./MarketingApp";
|
||||
import "./styles/marketing.css";
|
||||
import "../styles/igny8-colors.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
|
||||
|
||||
@@ -38,14 +38,24 @@ import {
|
||||
ArrowRightIcon
|
||||
} from '../../icons';
|
||||
|
||||
/**
|
||||
* Pipeline stage configuration with consistent design system colors:
|
||||
* - Keywords → Clusters: brand/primary (keywords side) to purple (clusters side)
|
||||
* - Clusters → Ideas: purple to warning/amber
|
||||
* - Ideas → Tasks: warning/amber to brand/primary
|
||||
* - Tasks → Content: brand/primary to success/green
|
||||
* - Content → Image Prompts: success to purple
|
||||
* - Image Prompts → Images: purple
|
||||
* - Review Gate: warning/amber
|
||||
*/
|
||||
const STAGE_CONFIG = [
|
||||
{ icon: ListIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600', hoverColor: 'hover:border-brand-500', name: 'Keywords → Clusters' },
|
||||
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' },
|
||||
{ icon: CheckCircleIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Ideas → Tasks' },
|
||||
{ icon: PencilIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600', hoverColor: 'hover:border-success-500', name: 'Tasks → Content' },
|
||||
{ icon: FileIcon, color: 'from-warning-500 to-warning-600', textColor: 'text-warning-600', hoverColor: 'hover:border-warning-500', name: 'Content → Image Prompts' },
|
||||
{ icon: FileTextIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Image Prompts → Images' },
|
||||
{ icon: PaperPlaneIcon, color: 'from-success-500 to-success-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Review Gate' },
|
||||
{ icon: ListIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Keywords → Clusters' },
|
||||
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' },
|
||||
{ icon: BoltIcon, color: 'from-warning-500 to-warning-600', textColor: 'text-warning-600 dark:text-warning-400', bgColor: 'bg-warning-100 dark:bg-warning-900/30', hoverColor: 'hover:border-warning-500', name: 'Ideas → Tasks' },
|
||||
{ icon: CheckCircleIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Tasks → Content' },
|
||||
{ icon: PencilIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/30', hoverColor: 'hover:border-success-500', name: 'Content → Image Prompts' },
|
||||
{ icon: FileIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Image Prompts → Images' },
|
||||
{ icon: PaperPlaneIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/30', hoverColor: 'hover:border-success-500', name: 'Review Gate' },
|
||||
];
|
||||
|
||||
const AutomationPage: React.FC = () => {
|
||||
@@ -421,8 +431,8 @@ const AutomationPage: React.FC = () => {
|
||||
|
||||
{/* Compact Schedule & Controls Panel */}
|
||||
{config && (
|
||||
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700">
|
||||
<div className="flex flex-col lg:flex-row items-center lg:items-center justify-between gap-3">
|
||||
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700 [&>div]:!py-1.5 [&>div]:!px-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{config.is_enabled ? (
|
||||
@@ -454,24 +464,27 @@ const AutomationPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
||||
${currentRun?.status === 'running' ? 'border-brand-500 bg-brand-50' : currentRun?.status === 'paused' ? 'border-warning-500 bg-warning-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-gray-300 bg-gray-50'}`}>
|
||||
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-brand-500 to-brand-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-warning-500 to-warning-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
|
||||
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-4 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-4 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-4 text-white" /> : <BoltIcon className="size-4 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
|
||||
{currentRun?.status === 'paused' && 'Paused'}
|
||||
{!currentRun && totalPending > 0 && 'Ready to Run'}
|
||||
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
||||
|
||||
{/* Ready to Run Card - Inline horizontal */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all
|
||||
${currentRun?.status === 'running' ? 'border-brand-300 bg-white' : currentRun?.status === 'paused' ? 'border-warning-300 bg-white' : totalPending > 0 ? 'border-success-300 bg-white' : 'border-white/30 bg-white/10'}`}>
|
||||
<div className={`size-6 rounded-md flex items-center justify-center flex-shrink-0
|
||||
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-brand-500 to-brand-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-warning-500 to-warning-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
|
||||
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-3.5 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-3.5 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-3.5 text-white" /> : <BoltIcon className="size-3.5 text-white" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-sm font-semibold ${totalPending > 0 || currentRun ? 'text-gray-900' : 'text-white/90'}`}>
|
||||
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
|
||||
{currentRun?.status === 'paused' && 'Paused'}
|
||||
{!currentRun && totalPending > 0 && 'Ready to Run'}
|
||||
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
||||
</span>
|
||||
<span className={`text-xs ${totalPending > 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}>
|
||||
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowConfigModal(true)}
|
||||
@@ -588,20 +601,20 @@ const AutomationPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Ideas */}
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-4 border-2 border-purple-200 dark:border-purple-800">
|
||||
<div className="bg-gradient-to-br from-warning-50 to-warning-100 dark:from-warning-900/20 dark:to-warning-800/20 rounded-xl p-4 border-2 border-warning-200 dark:border-warning-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center">
|
||||
<CheckCircleIcon className="size-5 text-white" />
|
||||
<div className="size-10 rounded-lg bg-gradient-to-br from-warning-500 to-warning-600 flex items-center justify-center">
|
||||
<BoltIcon className="size-5 text-white" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-purple-900 dark:text-purple-100">Ideas</div>
|
||||
<div className="text-sm font-bold text-warning-900 dark:text-warning-100">Ideas</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const res = getStageResult(3);
|
||||
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-purple-900">{total}</div>
|
||||
<div className="text-3xl font-bold text-warning-900">{total}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -613,9 +626,9 @@ const AutomationPage: React.FC = () => {
|
||||
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
|
||||
return (
|
||||
renderMetricRow([
|
||||
{ label: 'New:', value: newCount, colorCls: 'text-purple-700' },
|
||||
{ label: 'Queued:', value: queued, colorCls: 'text-purple-700' },
|
||||
{ label: 'Completed:', value: completed, colorCls: 'text-purple-700' },
|
||||
{ label: 'New:', value: newCount, colorCls: 'text-warning-700' },
|
||||
{ label: 'Queued:', value: queued, colorCls: 'text-warning-700' },
|
||||
{ label: 'Completed:', value: completed, colorCls: 'text-warning-700' },
|
||||
])
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { linkerApi } from '../../api/linker.api';
|
||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
@@ -241,40 +240,7 @@ export default function LinkerContentList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Total Content',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `${content.filter(c => (c.internal_links?.length || 0) > 0).length} with links`,
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/linker/content',
|
||||
},
|
||||
{
|
||||
title: 'Links Added',
|
||||
value: content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0).toLocaleString(),
|
||||
subtitle: `${Object.keys(linkResults).length} processed`,
|
||||
icon: <PlugInIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
},
|
||||
{
|
||||
title: 'Avg Links/Content',
|
||||
value: content.length > 0
|
||||
? (content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0) / content.length).toFixed(1)
|
||||
: '0',
|
||||
subtitle: `${content.filter(c => c.linker_version && c.linker_version > 0).length} optimized`,
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Linking Progress',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => (c.internal_links?.length || 0) > 0).length / totalCount) * 100) : 0,
|
||||
color: 'primary',
|
||||
}}
|
||||
/>
|
||||
{/* Module footer placeholder - module on hold */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { optimizerApi, EntryPoint } from '../../api/optimizer.api';
|
||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
@@ -319,46 +318,7 @@ export default function OptimizerContentSelector() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Total Content',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `${filteredContent.length} filtered`,
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/optimizer/content',
|
||||
},
|
||||
{
|
||||
title: 'Optimized',
|
||||
value: content.filter(c => c.optimizer_version && c.optimizer_version > 0).length.toLocaleString(),
|
||||
subtitle: `${processing.length} processing`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'orange',
|
||||
},
|
||||
{
|
||||
title: 'Avg Score',
|
||||
value: content.length > 0 && content.some(c => c.optimization_scores?.overall_score)
|
||||
? (content
|
||||
.filter(c => c.optimization_scores?.overall_score)
|
||||
.reduce((sum, c) => sum + (c.optimization_scores?.overall_score || 0), 0) /
|
||||
content.filter(c => c.optimization_scores?.overall_score).length
|
||||
).toFixed(1)
|
||||
: '-',
|
||||
subtitle: `${content.filter(c => c.optimization_scores?.overall_score && c.optimization_scores.overall_score >= 80).length} high score`,
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Optimization Progress',
|
||||
value: totalCount > 0
|
||||
? Math.round((content.filter(c => c.optimizer_version && c.optimizer_version > 0).length / totalCount) * 100)
|
||||
: 0,
|
||||
color: 'warning',
|
||||
}}
|
||||
/>
|
||||
{/* Module footer placeholder - module on hold */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
|
||||
export default function Clusters() {
|
||||
const toast = useToast();
|
||||
@@ -560,8 +560,8 @@ export default function Clusters() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="green"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
@@ -580,62 +580,15 @@ export default function Clusters() {
|
||||
hint: totalReady > 0
|
||||
? `${totalReady} clusters ready for idea generation`
|
||||
: 'All clusters have ideas!',
|
||||
statusInsight: totalReady > 0
|
||||
? `Select clusters and generate ideas to create content topics.`
|
||||
: totalWithIdeas > 0
|
||||
? `Ideas generated. Go to Ideas page to queue them for writing.`
|
||||
: `No clusters yet. Run clustering on Keywords page first.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
|
||||
fromHref: '/planner/keywords',
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: totalCount,
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
toHref: '/planner/ideas',
|
||||
progress: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
fromHref: '/planner/ideas',
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: 0,
|
||||
toHref: '/writer/tasks',
|
||||
progress: 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
|
||||
{ label: 'Clusters Created', value: totalCount, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="planner"
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { createIdeasPageConfig } from '../../config/pages/ideas.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
|
||||
export default function Ideas() {
|
||||
const toast = useToast();
|
||||
@@ -483,8 +483,8 @@ export default function Ideas() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="amber"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
@@ -503,62 +503,15 @@ export default function Ideas() {
|
||||
hint: totalPending > 0
|
||||
? `${totalPending} ideas ready to become tasks`
|
||||
: 'All ideas converted!',
|
||||
statusInsight: totalPending > 0
|
||||
? `Select ideas and queue them to Writer to start content generation.`
|
||||
: totalInTasks > 0
|
||||
? `Ideas queued. Go to Writer Tasks to generate content.`
|
||||
: `No ideas yet. Generate ideas from Clusters page.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
|
||||
fromHref: '/planner/keywords',
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: clusters.length,
|
||||
toHref: '/planner/clusters',
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: clusters.length,
|
||||
fromHref: '/planner/clusters',
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: totalCount,
|
||||
progress: 100,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length,
|
||||
toHref: '/writer/tasks',
|
||||
progress: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'queued' || i.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
|
||||
{ label: 'Clusters Created', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: totalCount, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="planner"
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
@@ -780,8 +780,8 @@ export default function Keywords() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="blue"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
@@ -800,65 +800,15 @@ export default function Keywords() {
|
||||
hint: totalUnmapped > 0
|
||||
? `${totalUnmapped} keywords ready to cluster`
|
||||
: 'All keywords clustered!',
|
||||
statusInsight: totalUnmapped > 0
|
||||
? `Select unmapped keywords and run clustering to group them into topics.`
|
||||
: totalClustered > 0
|
||||
? `Keywords are clustered. Go to Clusters to generate content ideas.`
|
||||
: `Add keywords to begin. Import from CSV or add manually.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: clusters.length,
|
||||
toHref: '/planner/clusters',
|
||||
progress: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: clusters.length,
|
||||
fromHref: '/planner/clusters',
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
toHref: '/planner/ideas',
|
||||
progress: clusters.length > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / clusters.length) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
fromHref: '/planner/ideas',
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: 0,
|
||||
toHref: '/writer/tasks',
|
||||
progress: 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: keywords.filter(k => k.cluster_id).length, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="planner"
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createApprovedPageConfig } from '../../config/pages/approved.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
|
||||
export default function Approved() {
|
||||
const toast = useToast();
|
||||
@@ -417,7 +417,7 @@ export default function Approved() {
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="green"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
@@ -435,65 +435,13 @@ export default function Approved() {
|
||||
hint: content.filter(c => !c.external_id).length > 0
|
||||
? `${content.filter(c => !c.external_id).length} article${content.filter(c => !c.external_id).length !== 1 ? 's' : ''} pending sync to site`
|
||||
: 'All articles synced to site!',
|
||||
statusInsight: content.filter(c => !c.external_id).length > 0
|
||||
? `Select articles and publish to your WordPress site.`
|
||||
: totalCount > 0
|
||||
? `All content published! Check your site for live articles.`
|
||||
: `No approved content. Approve articles from Review page.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: 0,
|
||||
toHref: '/writer/content',
|
||||
progress: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: totalCount,
|
||||
toHref: '/writer/published',
|
||||
progress: 100,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: totalCount, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="writer"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
import { PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Content() {
|
||||
@@ -348,83 +348,35 @@ export default function Content() {
|
||||
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="blue"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'blue',
|
||||
metrics: [
|
||||
{ label: 'Drafts', value: content.filter(c => c.status === 'draft').length },
|
||||
{ label: 'Has Images', value: content.filter(c => c.has_generated_images).length, percentage: `${content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0}%` },
|
||||
{ label: 'In Review', value: content.filter(c => c.status === 'review').length },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length },
|
||||
{ label: 'Drafts', value: totalDraft },
|
||||
{ label: 'Has Images', value: content.filter(c => c.has_generated_images).length, percentage: `${totalDraft > 0 ? Math.round((content.filter(c => c.has_generated_images).length / totalDraft) * 100) : 0}%` },
|
||||
{ label: 'In Review', value: totalReview },
|
||||
{ label: 'Published', value: totalPublished },
|
||||
],
|
||||
progress: {
|
||||
value: content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0,
|
||||
value: totalDraft > 0 ? Math.round((content.filter(c => c.has_generated_images).length / totalDraft) * 100) : 0,
|
||||
label: 'Have Images',
|
||||
color: 'blue',
|
||||
},
|
||||
hint: content.filter(c => c.status === 'draft' && !c.has_generated_images).length > 0
|
||||
? `${content.filter(c => c.status === 'draft' && !c.has_generated_images).length} drafts need images before review`
|
||||
: 'All drafts have images!',
|
||||
statusInsight: content.filter(c => c.status === 'draft' && !c.has_generated_images).length > 0
|
||||
? `Generate images for drafts, then submit to Review.`
|
||||
: totalDraft > 0
|
||||
? `Select drafts and submit to Review for approval.`
|
||||
: `No drafts. Generate content from Tasks page.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalCount,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: content.filter(c => c.status === 'draft').length,
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: content.filter(c => c.status === 'draft').length,
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: content.filter(c => c.has_generated_images).length,
|
||||
toHref: '/writer/images',
|
||||
progress: content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: content.filter(c => c.status === 'review').length,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: content.filter(c => c.status === 'published').length,
|
||||
toHref: '/writer/published',
|
||||
progress: content.filter(c => c.status === 'review').length > 0 ? Math.round((content.filter(c => c.status === 'published').length / (content.filter(c => c.status === 'review').length + content.filter(c => c.status === 'published').length)) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: totalCount, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="writer"
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
|
||||
@@ -27,7 +27,7 @@ import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQu
|
||||
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
|
||||
export default function Images() {
|
||||
const toast = useToast();
|
||||
@@ -664,84 +664,35 @@ export default function Images() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="purple"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'purple',
|
||||
metrics: [
|
||||
{ label: 'Total', value: totalCount },
|
||||
{ label: 'Generated', value: images.filter(i => i.images?.some(img => img.status === 'generated')).length, percentage: `${totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Pending', value: images.filter(i => i.images?.some(img => img.status === 'pending')).length },
|
||||
{ label: 'Content Items', value: totalCount },
|
||||
{ label: 'Complete', value: totalComplete, percentage: `${totalCount > 0 ? Math.round((totalComplete / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Partial', value: totalPartial },
|
||||
{ label: 'Pending', value: totalPending },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0,
|
||||
label: 'Generated',
|
||||
value: totalCount > 0 ? Math.round((totalComplete / totalCount) * 100) : 0,
|
||||
label: 'Complete',
|
||||
color: 'purple',
|
||||
},
|
||||
hint: images.filter(i => i.images?.some(img => img.status === 'pending')).length > 0
|
||||
? `${images.filter(i => i.images?.some(img => img.status === 'pending')).length} content item${images.filter(i => i.images?.some(img => img.status === 'pending')).length !== 1 ? 's' : ''} need image generation`
|
||||
hint: totalPending > 0
|
||||
? `${totalPending} content item${totalPending !== 1 ? 's' : ''} need image generation`
|
||||
: 'All images generated!',
|
||||
statusInsight: totalPending > 0
|
||||
? `Select content items and generate images for articles.`
|
||||
: totalComplete > 0
|
||||
? `Images ready. Submit content to Review for publishing.`
|
||||
: `No content with image prompts. Generate content first.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: 0,
|
||||
toHref: '/writer/content',
|
||||
progress: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 100,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: 0,
|
||||
toHref: '/writer/published',
|
||||
progress: 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="writer"
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { createReviewPageConfig } from '../../config/pages/review.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
|
||||
export default function Review() {
|
||||
const toast = useToast();
|
||||
@@ -487,7 +487,7 @@ export default function Review() {
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="amber"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
@@ -504,65 +504,11 @@ export default function Review() {
|
||||
hint: totalCount > 0
|
||||
? `${totalCount} article${totalCount !== 1 ? 's' : ''} ready for review and publishing`
|
||||
: 'No content pending review',
|
||||
statusInsight: totalCount > 0
|
||||
? `Review content, edit if needed, then approve for publishing.`
|
||||
: `No content in review. Submit drafts from Content page.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalTasks,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: totalDrafts,
|
||||
toHref: '/writer/content',
|
||||
progress: totalTasks > 0 ? Math.round((totalDrafts / totalTasks) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: totalDrafts,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: totalDrafts > 0 ? Math.round((totalImagesCount / totalDrafts) * 100) : 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: totalCount,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: totalApproved,
|
||||
toHref: '/writer/approved',
|
||||
progress: totalCount > 0 ? Math.round((totalApproved / (totalCount + totalApproved)) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/approved' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: totalDrafts + totalCount + totalApproved, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: totalApproved, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
module="writer"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { createTasksPageConfig } from '../../config/pages/tasks.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Tasks() {
|
||||
@@ -546,84 +546,35 @@ export default function Tasks() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
{/* Three Widget Footer - Section 3 Layout with Standardized Widgets */}
|
||||
<StandardThreeWidgetFooter
|
||||
submoduleColor="blue"
|
||||
module="writer"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'blue',
|
||||
metrics: [
|
||||
{ label: 'Total', value: totalCount },
|
||||
{ label: 'Complete', value: tasks.filter(t => t.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Queue', value: tasks.filter(t => t.status === 'queued').length },
|
||||
{ label: 'Processing', value: tasks.filter(t => t.status === 'in_progress').length },
|
||||
{ label: 'Complete', value: totalCompleted, percentage: `${totalCount > 0 ? Math.round((totalCompleted / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Queue', value: totalQueued },
|
||||
{ label: 'Processing', value: totalProcessing },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
value: totalCount > 0 ? Math.round((totalCompleted / totalCount) * 100) : 0,
|
||||
label: 'Generated',
|
||||
color: 'blue',
|
||||
},
|
||||
hint: tasks.filter(t => t.status === 'queued').length > 0
|
||||
? `${tasks.filter(t => t.status === 'queued').length} tasks in queue for content generation`
|
||||
hint: totalQueued > 0
|
||||
? `${totalQueued} tasks in queue for content generation`
|
||||
: 'All tasks processed!',
|
||||
statusInsight: totalQueued > 0
|
||||
? `Select tasks and run content generation to create articles.`
|
||||
: totalCompleted > 0
|
||||
? `Content generated. Review drafts on the Content page.`
|
||||
: `No tasks yet. Queue ideas from the Planner Ideas page.`,
|
||||
}}
|
||||
moduleStats={{
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: tasks.filter(t => t.status === 'completed').length,
|
||||
toHref: '/writer/content',
|
||||
progress: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: tasks.filter(t => t.status === 'completed').length,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: 0,
|
||||
toHref: '/writer/published',
|
||||
progress: 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
}}
|
||||
completion={{
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
|
||||
{ label: 'Clusters Created', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
showCredits={true}
|
||||
analyticsHref="/account/usage"
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
|
||||
260
frontend/src/pages/legal/Privacy.tsx
Normal file
260
frontend/src/pages/legal/Privacy.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Privacy Policy Page
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { ChevronLeftIcon } from '../../icons';
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="Privacy Policy - IGNY8"
|
||||
description="IGNY8 Privacy Policy - Learn how we protect your data"
|
||||
/>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-8"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<Link to="/" className="inline-block mb-6">
|
||||
<img
|
||||
src="/igny8-logo-trnsp.png"
|
||||
alt="IGNY8"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Last updated: December 31, 2024
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 sm:p-12 space-y-8">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
1. Introduction
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
At IGNY8, we take your privacy seriously. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our AI-powered content creation platform. Please read this privacy policy carefully.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
2. Information We Collect
|
||||
</h2>
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-3">
|
||||
Personal Information
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We may collect personal information that you voluntarily provide when registering for an account, including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4 mb-4">
|
||||
<li>Name and email address</li>
|
||||
<li>Account credentials</li>
|
||||
<li>Billing information and payment details</li>
|
||||
<li>Company/organization name</li>
|
||||
<li>Website URLs you connect to our service</li>
|
||||
</ul>
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-3">
|
||||
Usage Information
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We automatically collect certain information when you use our Service:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Device and browser information</li>
|
||||
<li>IP address and location data</li>
|
||||
<li>Pages visited and features used</li>
|
||||
<li>Content and keywords you create or analyze</li>
|
||||
<li>Usage patterns and preferences</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
3. How We Use Your Information
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We use the collected information for various purposes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>To provide and maintain our Service</li>
|
||||
<li>To process your transactions and manage your subscription</li>
|
||||
<li>To send you service-related communications</li>
|
||||
<li>To improve and personalize your experience</li>
|
||||
<li>To analyze usage patterns and optimize our Service</li>
|
||||
<li>To protect against fraud and unauthorized access</li>
|
||||
<li>To comply with legal obligations</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
4. AI-Generated Content
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
When you use our AI content generation features:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Your input prompts may be processed by third-party AI providers</li>
|
||||
<li>Generated content is stored in your account</li>
|
||||
<li>We do not use your specific content to train AI models</li>
|
||||
<li>You retain ownership of the content you generate</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
5. Data Sharing and Disclosure
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We may share your information with:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li><strong>Service Providers:</strong> Third parties that help us operate our Service (payment processors, hosting providers, AI service providers)</li>
|
||||
<li><strong>Analytics Partners:</strong> To help us understand usage patterns</li>
|
||||
<li><strong>Legal Requirements:</strong> When required by law or to protect our rights</li>
|
||||
<li><strong>Business Transfers:</strong> In connection with a merger, acquisition, or sale of assets</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mt-4">
|
||||
We do not sell your personal information to third parties.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
6. Data Security
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We implement appropriate technical and organizational measures to protect your data:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Encryption of data in transit and at rest</li>
|
||||
<li>Regular security audits and updates</li>
|
||||
<li>Access controls and authentication measures</li>
|
||||
<li>Employee training on data protection</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
7. Data Retention
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
We retain your personal information for as long as your account is active or as needed to provide you services. We may retain certain information as required by law or for legitimate business purposes such as fraud prevention and analytics.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
8. Your Rights
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
Depending on your location, you may have the following rights:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
||||
<li><strong>Correction:</strong> Request correction of inaccurate data</li>
|
||||
<li><strong>Deletion:</strong> Request deletion of your data</li>
|
||||
<li><strong>Portability:</strong> Request transfer of your data</li>
|
||||
<li><strong>Objection:</strong> Object to certain processing activities</li>
|
||||
<li><strong>Withdrawal:</strong> Withdraw consent where processing is based on consent</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mt-4">
|
||||
To exercise these rights, contact us at privacy@igny8.com.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
9. Cookies and Tracking
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
We use cookies and similar technologies to:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Keep you logged in</li>
|
||||
<li>Remember your preferences</li>
|
||||
<li>Analyze how you use our Service</li>
|
||||
<li>Improve our Service</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mt-4">
|
||||
You can manage cookie preferences through your browser settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
10. International Data Transfers
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Your information may be transferred to and processed in countries other than your own. We ensure appropriate safeguards are in place for such transfers in compliance with applicable data protection laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
11. Children's Privacy
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Our Service is not intended for individuals under the age of 18. We do not knowingly collect personal information from children. If we become aware that we have collected personal information from a child, we will take steps to delete such information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
12. Changes to This Policy
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date. We encourage you to review this Privacy Policy periodically.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
13. Contact Us
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
If you have questions or concerns about this Privacy Policy, please contact us at:
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<strong>Email:</strong>{' '}
|
||||
<span className="text-brand-500">privacy@igny8.com</span>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<strong>General Inquiries:</strong>{' '}
|
||||
<span className="text-brand-500">support@igny8.com</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link to="/terms" className="hover:text-brand-500 mr-4">
|
||||
Terms and Conditions
|
||||
</Link>
|
||||
<Link to="/signup" className="hover:text-brand-500">
|
||||
Back to Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/legal/Terms.tsx
Normal file
186
frontend/src/pages/legal/Terms.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Terms and Conditions Page
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { ChevronLeftIcon } from '../../icons';
|
||||
|
||||
export default function Terms() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="Terms and Conditions - IGNY8"
|
||||
description="IGNY8 Terms and Conditions - Read our terms of service"
|
||||
/>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-8"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<Link to="/" className="inline-block mb-6">
|
||||
<img
|
||||
src="/igny8-logo-trnsp.png"
|
||||
alt="IGNY8"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Terms and Conditions
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Last updated: December 31, 2024
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 sm:p-12 space-y-8">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
1. Acceptance of Terms
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
By accessing and using IGNY8 ("the Service"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by these terms, please do not use this Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
2. Description of Service
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
IGNY8 is an AI-powered content creation and SEO optimization platform that provides:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Keyword research and clustering tools</li>
|
||||
<li>AI-generated content creation</li>
|
||||
<li>Image generation capabilities</li>
|
||||
<li>Content optimization features</li>
|
||||
<li>WordPress publishing integration</li>
|
||||
<li>Analytics and reporting</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
3. User Accounts
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
To use certain features of the Service, you must register for an account. When you register:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>You agree to provide accurate, current, and complete information</li>
|
||||
<li>You are responsible for maintaining the security of your account</li>
|
||||
<li>You are responsible for all activities that occur under your account</li>
|
||||
<li>You must notify us immediately of any unauthorized use of your account</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
4. Billing and Credits
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
Our Service operates on a credit-based system:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Credits are consumed when using AI features</li>
|
||||
<li>Unused credits may expire according to your plan terms</li>
|
||||
<li>Refunds are subject to our refund policy</li>
|
||||
<li>Prices are subject to change with notice</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
5. Acceptable Use
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
You agree not to use the Service to:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>Generate illegal, harmful, or offensive content</li>
|
||||
<li>Violate intellectual property rights</li>
|
||||
<li>Attempt to circumvent usage limits or security measures</li>
|
||||
<li>Resell or redistribute the Service without authorization</li>
|
||||
<li>Use automated systems to access the Service in a manner that exceeds reasonable usage</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
6. Intellectual Property
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed mb-4">
|
||||
Content generated through our Service:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>You retain ownership of content you create using the Service</li>
|
||||
<li>You grant us a license to process your content to provide the Service</li>
|
||||
<li>AI-generated content is provided "as-is" and you are responsible for reviewing and editing</li>
|
||||
<li>The Service itself, including all code, designs, and features, remains our property</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
7. Limitation of Liability
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
IGNY8 and its affiliates shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of the Service. Our total liability shall not exceed the amount you paid for the Service in the twelve months preceding the claim.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
8. Termination
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
We may terminate or suspend your access to the Service immediately, without prior notice, for conduct that we believe violates these Terms or is harmful to other users, us, or third parties, or for any other reason at our sole discretion.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
9. Changes to Terms
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
We reserve the right to modify these terms at any time. We will notify users of any material changes via email or through the Service. Your continued use of the Service after such modifications constitutes acceptance of the updated terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
10. Contact Information
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
If you have any questions about these Terms, please contact us at:
|
||||
</p>
|
||||
<p className="text-brand-500 mt-2">
|
||||
support@igny8.com
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link to="/privacy" className="hover:text-brand-500 mr-4">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link to="/signup" className="hover:text-brand-500">
|
||||
Back to Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,70 @@
|
||||
# Design Tokens & Styles
|
||||
|
||||
This directory contains the centralized design token system and legacy compatibility styles.
|
||||
This directory contains the centralized design token system for the IGNY8 application.
|
||||
|
||||
## Files
|
||||
|
||||
- `tokens.css` - **Single source of truth** for all design tokens (colors, gradients, shadows)
|
||||
- `igny8-colors.css` - Legacy compatibility file (deprecated - use tokens.css instead)
|
||||
- `global.css` - Global base styles
|
||||
- `cms/` - CMS-specific styles
|
||||
|
||||
## Design Tokens (`tokens.css`)
|
||||
|
||||
All design tokens use plain naming (no "igny8" prefix):
|
||||
|
||||
### Color Tokens
|
||||
- `--color-primary` - Primary brand blue (#0693e3)
|
||||
- `--color-primary-dark` - Primary dark variant (#0472b8)
|
||||
- `--color-success` - Success green (#0bbf87)
|
||||
- `--color-warning` - Warning amber (#ff7a00)
|
||||
- `--color-danger` - Danger red (#ef4444)
|
||||
- `--color-purple` - Purple accent (#5d4ae3)
|
||||
- `--color-primary` - Primary brand blue (#2C7AA1)
|
||||
- `--color-primary-dark` - Primary dark variant (#236082)
|
||||
- `--color-success` - Success green (#2CA18E)
|
||||
- `--color-warning` - Warning amber (#D9A12C)
|
||||
- `--color-danger` - Danger red (#A12C40)
|
||||
- `--color-purple` - Purple accent (#2C40A1)
|
||||
|
||||
### Usage
|
||||
|
||||
**✅ DO:**
|
||||
```tsx
|
||||
// Use Tailwind utilities
|
||||
// Use Tailwind utilities with design system colors
|
||||
<div className="bg-brand-500 text-white">Content</div>
|
||||
|
||||
// Use CSS variables for custom values
|
||||
<div className="bg-[var(--color-primary)]">Content</div>
|
||||
// Use CSS variables for inline styles
|
||||
<div style={{ color: 'var(--color-primary)' }}>Content</div>
|
||||
|
||||
// Use React components
|
||||
// Use design system React components
|
||||
<Button tone="brand" variant="gradient">Click me</Button>
|
||||
|
||||
// Use colors.config.ts for programmatic access
|
||||
import { getModuleCSSColor } from '@/config/colors.config';
|
||||
const color = getModuleCSSColor('keywords'); // Returns computed CSS value
|
||||
```
|
||||
|
||||
**❌ DON'T:**
|
||||
```tsx
|
||||
// Don't use deprecated utility classes
|
||||
<div className="igny8-bg-blue">Content</div>
|
||||
// Don't hardcode hex colors
|
||||
<div className="bg-[#3B82F6]">Content</div>
|
||||
|
||||
// Don't hardcode colors
|
||||
<div className="bg-[#0693e3]">Content</div>
|
||||
// Don't use Tailwind default colors (blue-500, emerald-500, etc.)
|
||||
<div className="text-emerald-500">Content</div>
|
||||
|
||||
// Don't use inline hex colors
|
||||
<div style={{ color: '#F59E0B' }}>Content</div>
|
||||
```
|
||||
|
||||
## Legacy File (`igny8-colors.css`)
|
||||
## Active Utility Classes (in index.css)
|
||||
|
||||
⚠️ **DEPRECATED** - This file is maintained for backward compatibility only.
|
||||
The following utility classes are actively used:
|
||||
- `.igny8-table-*` - Table styling utilities
|
||||
- `.igny8-header-metric-*` - Header metrics bar styling
|
||||
- `.igny8-select-styled` - Dropdown arrow styling
|
||||
|
||||
- Legacy variable aliases (`--igny8-*` → `--color-*`)
|
||||
- Active utility classes (`.igny8-table-*`, `.igny8-header-metric-*`)
|
||||
- Deprecated utility classes (marked - do not use in new code)
|
||||
|
||||
See `MIGRATION_GUIDE.md` for migration instructions.
|
||||
These are defined in `/src/index.css`, not here.
|
||||
|
||||
## Migration
|
||||
|
||||
All new code should use:
|
||||
1. Design tokens from `tokens.css`
|
||||
2. Tailwind utilities (`bg-brand-500`, `text-brand-500`)
|
||||
All code should use:
|
||||
1. Design tokens from `tokens.css` via CSS variables
|
||||
2. Tailwind utilities mapped to design tokens (`bg-brand-500`, `text-success-500`)
|
||||
3. `colors.config.ts` for programmatic color access
|
||||
3. React components (`Button`, `Badge`, `Card`)
|
||||
|
||||
See `../MIGRATION_GUIDE.md` for complete migration guide.
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
/* ===================================================================
|
||||
IGNY8 UTILITY CLASSES & LEGACY SUPPORT
|
||||
===================================================================
|
||||
⚠️ DEPRECATED: This file is maintained for backward compatibility.
|
||||
New code should use:
|
||||
- CSS variables: var(--color-primary), var(--color-success), etc.
|
||||
- Tailwind utilities: bg-brand-500, text-brand-500, etc.
|
||||
- React components: Button, Badge, Card from /components/ui/
|
||||
|
||||
🔒 DESIGN SYSTEM LOCKED - See DESIGN_SYSTEM.md for complete style guide
|
||||
|
||||
This file provides:
|
||||
1. Legacy variable aliases (--igny8-* → --color-*)
|
||||
2. Active utility classes (.igny8-table-*, .igny8-header-metric-*)
|
||||
3. Deprecated utility classes (marked below - do not use in new code)
|
||||
=================================================================== */
|
||||
|
||||
/* === CSS CUSTOM PROPERTIES (Variables) === */
|
||||
/* Import tokens from centralized tokens.css */
|
||||
@import "./tokens.css";
|
||||
|
||||
/* Legacy variable aliases for backward compatibility */
|
||||
/* These allow old code using --igny8-* to continue working */
|
||||
:root {
|
||||
--igny8-blue: var(--color-primary);
|
||||
--igny8-blue-dark: var(--color-primary-dark);
|
||||
--igny8-green: var(--color-success);
|
||||
--igny8-green-dark: var(--color-success-dark);
|
||||
--igny8-amber: var(--color-warning);
|
||||
--igny8-amber-dark: var(--color-warning-dark);
|
||||
--igny8-red: var(--color-danger);
|
||||
--igny8-red-dark: var(--color-danger-dark);
|
||||
--igny8-purple: var(--color-purple);
|
||||
--igny8-purple-dark: var(--color-purple-dark);
|
||||
--igny8-navy-bg: var(--color-navy);
|
||||
--igny8-navy-bg-2: var(--color-navy-light);
|
||||
--igny8-surface: var(--color-surface);
|
||||
--igny8-panel: var(--color-panel);
|
||||
--igny8-panel-2: var(--color-panel-alt);
|
||||
--igny8-text: var(--color-text);
|
||||
--igny8-text-dim: var(--color-text-dim);
|
||||
--igny8-text-light: var(--color-text-light);
|
||||
--igny8-stroke: var(--color-stroke);
|
||||
--igny8-radius: var(--radius-base);
|
||||
--igny8-gradient-blue: var(--gradient-primary);
|
||||
--igny8-gradient-success: var(--gradient-success);
|
||||
--igny8-gradient-warning: var(--gradient-warning);
|
||||
--igny8-gradient-danger: var(--gradient-danger);
|
||||
--igny8-gradient-purple: var(--gradient-purple);
|
||||
--igny8-gradient-panel: var(--gradient-panel);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
DEPRECATED UTILITY CLASSES
|
||||
===================================================================
|
||||
⚠️ DO NOT USE IN NEW CODE
|
||||
These classes are deprecated. Use Tailwind utilities or React components instead:
|
||||
- .igny8-bg-blue → bg-brand-500 or bg-[var(--color-primary)]
|
||||
- .igny8-text-blue → text-brand-500 or text-[var(--color-primary)]
|
||||
- .igny8-border-blue → border-brand-500 or border-[var(--color-primary)]
|
||||
|
||||
Migration: Replace with Tailwind utilities or use Button/Badge/Card components.
|
||||
=================================================================== */
|
||||
|
||||
/* === Background Colors (DEPRECATED) === */
|
||||
.igny8-bg-blue { background-color: var(--igny8-blue); }
|
||||
.igny8-bg-blue-dark { background-color: var(--igny8-blue-dark); }
|
||||
.igny8-bg-green { background-color: var(--igny8-green); }
|
||||
.igny8-bg-green-dark { background-color: var(--igny8-green-dark); }
|
||||
.igny8-bg-amber { background-color: var(--igny8-amber); }
|
||||
.igny8-bg-amber-dark { background-color: var(--igny8-amber-dark); }
|
||||
.igny8-bg-red { background-color: var(--igny8-red); }
|
||||
.igny8-bg-red-dark { background-color: var(--igny8-red-dark); }
|
||||
.igny8-bg-purple { background-color: var(--igny8-purple); }
|
||||
.igny8-bg-purple-dark { background-color: var(--igny8-purple-dark); }
|
||||
.igny8-bg-navy { background-color: var(--igny8-navy-bg); }
|
||||
.igny8-bg-navy-2 { background-color: var(--igny8-navy-bg-2); }
|
||||
.igny8-bg-surface { background-color: var(--igny8-surface); }
|
||||
.igny8-bg-panel { background-color: var(--igny8-panel); }
|
||||
.igny8-bg-panel-2 { background-color: var(--igny8-panel-2); }
|
||||
|
||||
/* === Text Colors === */
|
||||
.igny8-text-blue { color: var(--igny8-blue); }
|
||||
.igny8-text-blue-dark { color: var(--igny8-blue-dark); }
|
||||
.igny8-text-green { color: var(--igny8-green); }
|
||||
.igny8-text-green-dark { color: var(--igny8-green-dark); }
|
||||
.igny8-text-amber { color: var(--igny8-amber); }
|
||||
.igny8-text-amber-dark { color: var(--igny8-amber-dark); }
|
||||
.igny8-text-red { color: var(--igny8-red); }
|
||||
.igny8-text-red-dark { color: var(--igny8-red-dark); }
|
||||
.igny8-text-purple { color: var(--igny8-purple); }
|
||||
.igny8-text-purple-dark { color: var(--igny8-purple-dark); }
|
||||
.igny8-text-primary { color: var(--igny8-text); }
|
||||
.igny8-text-dim { color: var(--igny8-text-dim); }
|
||||
.igny8-text-light { color: var(--igny8-text-light); }
|
||||
|
||||
/* === Border Colors === */
|
||||
.igny8-border-blue { border-color: var(--igny8-blue); }
|
||||
.igny8-border-blue-dark { border-color: var(--igny8-blue-dark); }
|
||||
.igny8-border-green { border-color: var(--igny8-green); }
|
||||
.igny8-border-amber { border-color: var(--igny8-amber); }
|
||||
.igny8-border-red { border-color: var(--igny8-red); }
|
||||
.igny8-border-purple { border-color: var(--igny8-purple); }
|
||||
.igny8-border-stroke { border-color: var(--igny8-stroke); }
|
||||
|
||||
/* === Gradient Backgrounds === */
|
||||
.igny8-gradient-blue { background: var(--igny8-gradient-blue); }
|
||||
.igny8-gradient-success { background: var(--igny8-gradient-success); }
|
||||
.igny8-gradient-warning { background: var(--igny8-gradient-warning); }
|
||||
.igny8-gradient-danger { background: var(--igny8-gradient-danger); }
|
||||
.igny8-gradient-purple { background: var(--igny8-gradient-purple); }
|
||||
.igny8-gradient-panel { background: var(--igny8-gradient-panel); }
|
||||
|
||||
/* === Tailwind Gradient Utilities (for use with bg-gradient-to-*) === */
|
||||
.igny8-from-blue { --tw-gradient-from: var(--igny8-blue); --tw-gradient-to: var(--igny8-blue-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.igny8-to-blue-dark { --tw-gradient-to: var(--igny8-blue-dark); }
|
||||
.igny8-from-green { --tw-gradient-from: var(--igny8-green); --tw-gradient-to: var(--igny8-green-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.igny8-to-green-dark { --tw-gradient-to: var(--igny8-green-dark); }
|
||||
.igny8-from-amber { --tw-gradient-from: var(--igny8-amber); --tw-gradient-to: var(--igny8-amber-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.igny8-to-amber-dark { --tw-gradient-to: var(--igny8-amber-dark); }
|
||||
.igny8-from-purple { --tw-gradient-from: var(--igny8-purple); --tw-gradient-to: var(--igny8-purple-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.igny8-to-purple-dark { --tw-gradient-to: var(--igny8-purple-dark); }
|
||||
|
||||
/* === Border Radius === */
|
||||
.igny8-rounded { border-radius: var(--igny8-radius); }
|
||||
.igny8-rounded-xl { border-radius: calc(var(--igny8-radius) * 2); }
|
||||
.igny8-rounded-2xl { border-radius: calc(var(--igny8-radius) * 3); }
|
||||
|
||||
/* === Hover States === */
|
||||
.igny8-hover-blue:hover { background-color: var(--igny8-blue-dark); }
|
||||
.igny8-hover-green:hover { background-color: var(--igny8-green-dark); }
|
||||
.igny8-hover-amber:hover { background-color: var(--igny8-amber-dark); }
|
||||
|
||||
/* === Card Styles (matching WordPress plugin) === */
|
||||
.igny8-card {
|
||||
background: var(--igny8-panel);
|
||||
border: 1px solid var(--igny8-stroke);
|
||||
border-radius: var(--igny8-radius);
|
||||
padding: 18px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.10), 0 4px 10px rgba(13, 27, 42, 0.06);
|
||||
transition: box-shadow 0.25s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.igny8-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.14), 0 8px 20px rgba(13, 27, 42, 0.10);
|
||||
}
|
||||
|
||||
.igny8-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
background: var(--igny8-gradient-blue);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--igny8-radius) var(--igny8-radius) 0 0;
|
||||
margin: -10px -10px 12px -10px;
|
||||
}
|
||||
|
||||
/* === Button Styles === */
|
||||
.igny8-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
border: none;
|
||||
border-radius: var(--igny8-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.igny8-btn-primary {
|
||||
background: var(--igny8-blue);
|
||||
}
|
||||
|
||||
.igny8-btn-primary:hover {
|
||||
background: var(--igny8-blue-dark);
|
||||
}
|
||||
|
||||
.igny8-btn-success {
|
||||
background: var(--igny8-green);
|
||||
}
|
||||
|
||||
.igny8-btn-success:hover {
|
||||
background: var(--igny8-green-dark);
|
||||
}
|
||||
|
||||
.igny8-btn-warning {
|
||||
background: var(--igny8-amber);
|
||||
}
|
||||
|
||||
.igny8-btn-warning:hover {
|
||||
background: var(--igny8-amber-dark);
|
||||
}
|
||||
|
||||
.igny8-btn-danger {
|
||||
background: var(--igny8-red);
|
||||
}
|
||||
|
||||
.igny8-btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* === Badge Styles === */
|
||||
.igny8-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.igny8-badge-primary { background: var(--igny8-blue); }
|
||||
.igny8-badge-success { background: var(--igny8-green); }
|
||||
.igny8-badge-warning { background: var(--igny8-amber); }
|
||||
.igny8-badge-danger { background: var(--igny8-red); }
|
||||
.igny8-badge-purple { background: var(--igny8-purple); }
|
||||
|
||||
/* ===================================================================
|
||||
COMPACT LAYOUT STYLES (Global - Apply to all pages)
|
||||
=================================================================== */
|
||||
|
||||
/* === Table Compact Styles === */
|
||||
/* Table header styling - larger font, taller height, differentiated from body */
|
||||
.igny8-table-compact th {
|
||||
padding: 12px 16px !important; /* Increased height for headers */
|
||||
font-size: 14px !important; /* Larger font for headers */
|
||||
font-weight: 600 !important;
|
||||
color: var(--color-gray-600) !important; /* gray-600 - darker for better visibility */
|
||||
text-align: left !important;
|
||||
background-color: var(--color-gray-50) !important; /* Light gray background */
|
||||
border-bottom: 2px solid var(--color-gray-200) !important; /* Thicker bottom border */
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.dark .igny8-table-compact th {
|
||||
color: var(--color-gray-200) !important; /* Lighter text for dark mode */
|
||||
background-color: rgba(15, 23, 42, 0.5) !important; /* Dark gray background */
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Table body cell styling - reduced padding for data density */
|
||||
.igny8-table-compact td {
|
||||
padding: 8px 12px !important; /* Reduced padding for body cells */
|
||||
font-size: 14px !important; /* Consistent size across all cells */
|
||||
border-bottom: 1px solid var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
.dark .igny8-table-compact td {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* === Compact Input/Select Styles === */
|
||||
.igny8-input-compact,
|
||||
.igny8-select-compact {
|
||||
height: 36px !important; /* Reduced from h-11 (44px) */
|
||||
padding: 6px 12px !important; /* Reduced padding */
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* === Compact Button Styles === */
|
||||
.igny8-btn-compact {
|
||||
padding: 6px 12px !important;
|
||||
font-size: 13px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.igny8-btn-compact-sm {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 12px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* === Styled Dropdown/Select === */
|
||||
.igny8-select-styled {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 12px center !important;
|
||||
padding-right: 36px !important;
|
||||
}
|
||||
|
||||
.dark .igny8-select-styled {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
/* Native select dropdown styling (limited but improved) */
|
||||
select.igny8-select-styled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select.igny8-select-styled option {
|
||||
padding: 10px 12px !important;
|
||||
background: white !important;
|
||||
color: var(--color-gray-700) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
select.igny8-select-styled option:hover {
|
||||
background: var(--color-gray-100) !important;
|
||||
}
|
||||
|
||||
select.igny8-select-styled option:checked {
|
||||
background: var(--color-purple-100) !important;
|
||||
color: var(--color-purple-600) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.dark select.igny8-select-styled option {
|
||||
background: var(--color-gray-800) !important;
|
||||
color: var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
.dark select.igny8-select-styled option:hover {
|
||||
background: var(--color-gray-700) !important;
|
||||
}
|
||||
|
||||
.dark select.igny8-select-styled option:checked {
|
||||
background: var(--color-purple-900) !important;
|
||||
color: var(--color-purple-200) !important;
|
||||
}
|
||||
|
||||
/* === Header Metrics Bar (compact, right-aligned) === */
|
||||
.igny8-header-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .igny8-header-metrics {
|
||||
background: transparent;
|
||||
box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.igny8-header-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.igny8-header-metric-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgb(203 213 225); /* slate-300 */
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-separator {
|
||||
background: rgb(148 163 184); /* slate-400 */
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.igny8-header-metric-label {
|
||||
font-size: 13px; /* increased from 10px by 25%+ */
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgb(100 116 139); /* slate-500 */
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-label {
|
||||
color: rgb(148 163 184); /* slate-400 */
|
||||
}
|
||||
|
||||
.igny8-header-metric-value {
|
||||
font-size: 16px; /* increased from 14px */
|
||||
font-weight: 700;
|
||||
color: rgb(30 41 59); /* slate-800 */
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Credits-specific value - 20% smaller than other metrics */
|
||||
.igny8-header-metric-value-credits {
|
||||
font-size: 13px; /* 16px * 0.8 = 12.8px ≈ 13px */
|
||||
}
|
||||
|
||||
.dark .igny8-header-metric-value {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.blue {
|
||||
background: var(--igny8-blue);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.green {
|
||||
background: var(--igny8-green);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.amber {
|
||||
background: var(--igny8-amber);
|
||||
}
|
||||
|
||||
.igny8-header-metric-accent.purple {
|
||||
background: var(--igny8-purple);
|
||||
}
|
||||
|
||||
/* === Difficulty Badge Special Styling === */
|
||||
.difficulty-badge {
|
||||
border-radius: 3px !important;
|
||||
min-width: 28px !important;
|
||||
display: inline-flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Very Hard (5) - Darker error background (not maroon, just darker error red) */
|
||||
.difficulty-badge.difficulty-very-hard {
|
||||
background-color: var(--color-error-600) !important; /* red-600 - darker than bg-error-500 (red-500) */
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.dark .difficulty-badge.difficulty-very-hard {
|
||||
background-color: var(--color-error-600) !important; /* red-600 - darker error */
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Center align Difficulty column header and cells */
|
||||
.igny8-table-compact th.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.igny8-table-compact td.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Smooth Table Height Transitions - Improved */
|
||||
.igny8-table-container {
|
||||
min-height: 500px; /* Stable height during loading */
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent scrollbar jumping during loading */
|
||||
/* Force layout stability */
|
||||
will-change: min-height;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading {
|
||||
min-height: 500px;
|
||||
overflow: hidden !important; /* Force hide all scrollbars during loading */
|
||||
/* Prevent any layout shifts */
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Smooth reveal */
|
||||
animation: fadeInContainer 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInContainer {
|
||||
from {
|
||||
opacity: 0.95;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table wrapper to prevent horizontal scrollbar jumping */
|
||||
.igny8-table-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
/* Initially hide scrollbars completely */
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
/* Smooth scrollbar appearance */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
|
||||
transition: opacity 0.4s ease-in-out;
|
||||
/* Prevent layout shift */
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
/* Hide scrollbars during loading - use multiple approaches */
|
||||
.igny8-table-container.loading .igny8-table-wrapper {
|
||||
overflow-x: hidden !important; /* Force hide horizontal scroll */
|
||||
overflow-y: hidden !important;
|
||||
/* Hide scrollbar track */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-wrapper::-webkit-scrollbar {
|
||||
display: none !important; /* Hide webkit scrollbars */
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-wrapper {
|
||||
overflow-x: auto; /* Show scrollbar only when loaded and stable */
|
||||
overflow-y: hidden;
|
||||
/* Smooth scrollbar fade-in */
|
||||
animation: showScrollbar 0.4s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes showScrollbar {
|
||||
from {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
to {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure table has stable width from start */
|
||||
.igny8-table-smooth {
|
||||
width: 100%;
|
||||
table-layout: fixed; /* Use fixed layout for stability */
|
||||
min-width: 100%; /* Prevent width jumping */
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
/* Prevent layout shifts */
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-smooth {
|
||||
opacity: 0.8;
|
||||
/* Force stable rendering */
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-smooth {
|
||||
opacity: 1;
|
||||
table-layout: auto; /* Switch to auto after loaded for better column sizing */
|
||||
transition: opacity 0.5s ease-in-out, table-layout 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Table body transitions */
|
||||
.igny8-table-body {
|
||||
position: relative;
|
||||
min-height: 450px; /* Fixed minimum height during loading */
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
|
||||
/* Prevent layout shifts */
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.igny8-table-container.loading .igny8-table-body {
|
||||
min-height: 450px;
|
||||
opacity: 1;
|
||||
/* Lock height during transition */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-body {
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
/* Smooth height adjustment */
|
||||
transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Smooth fade transition between skeleton and content */
|
||||
.igny8-table-container.loading .igny8-table-body > tr:not(.igny8-skeleton-row) {
|
||||
display: none !important; /* Force hide data rows during loading */
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.igny8-table-container.loaded .igny8-table-body > tr.igny8-skeleton-row {
|
||||
display: none !important; /* Force hide skeleton rows after loading */
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Smooth row appearance for data rows */
|
||||
.igny8-data-row {
|
||||
animation: fadeInRow 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
/* Smooth entrance */
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
@keyframes fadeInRow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable animation for skeleton rows */
|
||||
.igny8-skeleton-row {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
/* Keep visible during loading */
|
||||
display: table-row !important;
|
||||
}
|
||||
|
||||
/* Prevent any flash of unstyled content */
|
||||
.igny8-table-container.loading * {
|
||||
/* Prevent any content jumps */
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
Reference in New Issue
Block a user