many changes for modules widgets and colors and styling
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user