Compare commits
5 Commits
042e5c6735
...
5f9a4b8dca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9a4b8dca | ||
|
|
627938aa95 | ||
|
|
a145e6742e | ||
|
|
24cdb4fdf9 | ||
|
|
a1ec3100fd |
@@ -31,11 +31,15 @@ class AIEngine:
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"{count} cluster{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_content':
|
||||
return f"{count} task{'s' if count != 1 else ''}"
|
||||
return f"{count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_images':
|
||||
return f"{count} task{'s' if count != 1 else ''}"
|
||||
return f"{count} image{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
return f"{count} image prompt{'s' if count != 1 else ''}"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"{count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "1 site blueprint"
|
||||
return "site blueprint"
|
||||
return f"{count} item{'s' if count != 1 else ''}"
|
||||
|
||||
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
|
||||
@@ -51,12 +55,22 @@ class AIEngine:
|
||||
remaining = count - len(keyword_list)
|
||||
if remaining > 0:
|
||||
keywords_text = ', '.join(keyword_list)
|
||||
return f"Validating {keywords_text} and {remaining} more keyword{'s' if remaining != 1 else ''}"
|
||||
return f"Validating {count} keywords for clustering"
|
||||
else:
|
||||
keywords_text = ', '.join(keyword_list)
|
||||
return f"Validating {keywords_text}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load keyword names for validation message: {e}")
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"Analyzing {count} clusters for content opportunities"
|
||||
elif function_name == 'generate_content':
|
||||
return f"Preparing {count} article{'s' if count != 1 else ''} for generation"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
return f"Analyzing content for image opportunities"
|
||||
elif function_name == 'generate_images':
|
||||
return f"Queuing {count} image{'s' if count != 1 else ''} for generation"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"Analyzing {count} article{'s' if count != 1 else ''} for optimization"
|
||||
|
||||
# Fallback to simple count message
|
||||
return f"Validating {input_description}"
|
||||
@@ -64,24 +78,33 @@ class AIEngine:
|
||||
def _get_prep_message(self, function_name: str, count: int, data: Any) -> str:
|
||||
"""Get user-friendly prep message"""
|
||||
if function_name == 'auto_cluster':
|
||||
return f"Loading {count} keyword{'s' if count != 1 else ''}"
|
||||
return f"Analyzing keyword relationships for {count} keyword{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"Loading {count} cluster{'s' if count != 1 else ''}"
|
||||
# Count keywords in clusters if available
|
||||
keyword_count = 0
|
||||
if isinstance(data, dict) and 'cluster_data' in data:
|
||||
for cluster in data['cluster_data']:
|
||||
keyword_count += len(cluster.get('keywords', []))
|
||||
if keyword_count > 0:
|
||||
return f"Mapping {keyword_count} keywords to topic briefs"
|
||||
return f"Mapping keywords to topic briefs for {count} cluster{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_content':
|
||||
return f"Preparing {count} content idea{'s' if count != 1 else ''}"
|
||||
return f"Building content brief{'s' if count != 1 else ''} with target keywords"
|
||||
elif function_name == 'generate_images':
|
||||
return f"Extracting image prompts from {count} task{'s' if count != 1 else ''}"
|
||||
return f"Preparing AI image generation ({count} image{'s' if count != 1 else ''})"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
# Extract max_images from data if available
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
max_images = data[0].get('max_images')
|
||||
total_images = 1 + max_images # 1 featured + max_images in-article
|
||||
return f"Mapping Content for {total_images} Image Prompts"
|
||||
return f"Identifying 1 featured + {max_images} in-article image slots"
|
||||
elif isinstance(data, dict) and 'max_images' in data:
|
||||
max_images = data.get('max_images')
|
||||
total_images = 1 + max_images
|
||||
return f"Mapping Content for {total_images} Image Prompts"
|
||||
return f"Mapping Content for Image Prompts"
|
||||
return f"Identifying 1 featured + {max_images} in-article image slots"
|
||||
return f"Identifying featured and in-article image slots"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"Analyzing SEO factors for {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
blueprint_name = ''
|
||||
if isinstance(data, dict):
|
||||
@@ -94,13 +117,17 @@ class AIEngine:
|
||||
def _get_ai_call_message(self, function_name: str, count: int) -> str:
|
||||
"""Get user-friendly AI call message"""
|
||||
if function_name == 'auto_cluster':
|
||||
return f"Grouping {count} keyword{'s' if count != 1 else ''} into clusters"
|
||||
return f"Grouping {count} keywords by search intent"
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"Generating content ideas for {count} cluster{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_content':
|
||||
return f"Writing article{'s' if count != 1 else ''} with AI"
|
||||
return f"Writing {count} article{'s' if count != 1 else ''} with AI"
|
||||
elif function_name == 'generate_images':
|
||||
return f"Creating image{'s' if count != 1 else ''} with AI"
|
||||
return f"Generating image{'s' if count != 1 else ''} with AI"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
return f"Creating optimized prompts for {count} image{'s' if count != 1 else ''}"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"Optimizing {count} article{'s' if count != 1 else ''} for SEO"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "Designing complete site architecture"
|
||||
return f"Processing with AI"
|
||||
@@ -108,13 +135,17 @@ class AIEngine:
|
||||
def _get_parse_message(self, function_name: str) -> str:
|
||||
"""Get user-friendly parse message"""
|
||||
if function_name == 'auto_cluster':
|
||||
return "Organizing clusters"
|
||||
return "Organizing semantic clusters"
|
||||
elif function_name == 'generate_ideas':
|
||||
return "Structuring outlines"
|
||||
return "Structuring article outlines"
|
||||
elif function_name == 'generate_content':
|
||||
return "Formatting content"
|
||||
return "Formatting HTML content and metadata"
|
||||
elif function_name == 'generate_images':
|
||||
return "Processing images"
|
||||
return "Processing generated images"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
return "Refining contextual image descriptions"
|
||||
elif function_name == 'optimize_content':
|
||||
return "Compiling optimization scores"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "Compiling site map"
|
||||
return "Processing results"
|
||||
@@ -122,19 +153,21 @@ class AIEngine:
|
||||
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
|
||||
"""Get user-friendly parse message with count"""
|
||||
if function_name == 'auto_cluster':
|
||||
return f"{count} cluster{'s' if count != 1 else ''} created"
|
||||
return f"Organizing {count} semantic cluster{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"{count} idea{'s' if count != 1 else ''} created"
|
||||
return f"Structuring {count} article outline{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_content':
|
||||
return f"{count} article{'s' if count != 1 else ''} created"
|
||||
return f"Formatting {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_images':
|
||||
return f"{count} image{'s' if count != 1 else ''} created"
|
||||
return f"Processing {count} generated image{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
# Count is total prompts, in-article is count - 1 (subtract featured)
|
||||
in_article_count = max(0, count - 1)
|
||||
if in_article_count > 0:
|
||||
return f"Writing {in_article_count} In‑article Image Prompts"
|
||||
return "Writing In‑article Image Prompts"
|
||||
return f"Refining {in_article_count} in-article image description{'s' if in_article_count != 1 else ''}"
|
||||
return "Refining image descriptions"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"Compiling scores for {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
|
||||
return f"{count} item{'s' if count != 1 else ''} processed"
|
||||
@@ -142,20 +175,50 @@ class AIEngine:
|
||||
def _get_save_message(self, function_name: str, count: int) -> str:
|
||||
"""Get user-friendly save message"""
|
||||
if function_name == 'auto_cluster':
|
||||
return f"Saving {count} cluster{'s' if count != 1 else ''}"
|
||||
return f"Saving {count} cluster{'s' if count != 1 else ''} with keywords"
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"Saving {count} idea{'s' if count != 1 else ''}"
|
||||
return f"Saving {count} idea{'s' if count != 1 else ''} with outlines"
|
||||
elif function_name == 'generate_content':
|
||||
return f"Saving {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_images':
|
||||
return f"Saving {count} image{'s' if count != 1 else ''}"
|
||||
return f"Uploading {count} image{'s' if count != 1 else ''} to media library"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
# Count is total prompts created
|
||||
return f"Assigning {count} Prompts to Dedicated Slots"
|
||||
in_article = max(0, count - 1)
|
||||
return f"Assigning {count} prompts (1 featured + {in_article} in-article)"
|
||||
elif function_name == 'optimize_content':
|
||||
return f"Saving optimization scores for {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return f"Publishing {count} page blueprint{'s' if count != 1 else ''}"
|
||||
return f"Saving {count} item{'s' if count != 1 else ''}"
|
||||
|
||||
def _get_done_message(self, function_name: str, result: dict) -> str:
|
||||
"""Get user-friendly completion message with counts"""
|
||||
count = result.get('count', 0)
|
||||
|
||||
if function_name == 'auto_cluster':
|
||||
keyword_count = result.get('keywords_clustered', 0)
|
||||
return f"✓ Organized {keyword_count} keywords into {count} semantic cluster{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_ideas':
|
||||
return f"✓ Created {count} content idea{'s' if count != 1 else ''} with detailed outlines"
|
||||
elif function_name == 'generate_content':
|
||||
total_words = result.get('total_words', 0)
|
||||
if total_words > 0:
|
||||
return f"✓ Generated {count} article{'s' if count != 1 else ''} ({total_words:,} words)"
|
||||
return f"✓ Generated {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_images':
|
||||
return f"✓ Generated and saved {count} AI image{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_image_prompts':
|
||||
in_article = max(0, count - 1)
|
||||
return f"✓ Created {count} image prompt{'s' if count != 1 else ''} (1 featured + {in_article} in-article)"
|
||||
elif function_name == 'optimize_content':
|
||||
avg_score = result.get('average_score', 0)
|
||||
if avg_score > 0:
|
||||
return f"✓ Optimized {count} article{'s' if count != 1 else ''} (avg score: {avg_score}%)"
|
||||
return f"✓ Optimized {count} article{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return f"✓ Created {count} page blueprint{'s' if count != 1 else ''}"
|
||||
return f"✓ {count} item{'s' if count != 1 else ''} completed"
|
||||
|
||||
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
|
||||
"""
|
||||
Unified execution pipeline for all AI functions.
|
||||
@@ -411,9 +474,9 @@ class AIEngine:
|
||||
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||
|
||||
# Phase 6: DONE - Finalization (98-100%)
|
||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||
self.step_tracker.add_request_step("DONE", "success", "Task completed successfully")
|
||||
self.tracker.update("DONE", 100, "Task complete!", meta=self.step_tracker.get_meta())
|
||||
done_msg = self._get_done_message(function_name, save_result)
|
||||
self.step_tracker.add_request_step("DONE", "success", done_msg)
|
||||
self.tracker.update("DONE", 100, done_msg, meta=self.step_tracker.get_meta())
|
||||
|
||||
# Log to database
|
||||
self._log_to_database(fn, payload, parsed, save_result)
|
||||
|
||||
@@ -66,6 +66,8 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
active_sectors_count = serializers.SerializerMethodField()
|
||||
selected_sectors = serializers.SerializerMethodField()
|
||||
can_add_sectors = serializers.SerializerMethodField()
|
||||
keywords_count = serializers.SerializerMethodField()
|
||||
has_integration = serializers.SerializerMethodField()
|
||||
industry_name = serializers.CharField(source='industry.name', read_only=True)
|
||||
industry_slug = serializers.CharField(source='industry.slug', read_only=True)
|
||||
# Override domain field to use CharField instead of URLField to avoid premature validation
|
||||
@@ -79,7 +81,7 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
'is_active', 'status',
|
||||
'site_type', 'hosting_type', 'seo_metadata',
|
||||
'sectors_count', 'active_sectors_count', 'selected_sectors',
|
||||
'can_add_sectors',
|
||||
'can_add_sectors', 'keywords_count', 'has_integration',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||
@@ -161,6 +163,20 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
"""Check if site can add more sectors (max 5)."""
|
||||
return obj.can_add_sector()
|
||||
|
||||
def get_keywords_count(self, obj):
|
||||
"""Get total keywords count for the site across all sectors."""
|
||||
from igny8_core.modules.planner.models import Keywords
|
||||
return Keywords.objects.filter(site=obj).count()
|
||||
|
||||
def get_has_integration(self, obj):
|
||||
"""Check if site has an active WordPress integration."""
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
return SiteIntegration.objects.filter(
|
||||
site=obj,
|
||||
platform='wordpress',
|
||||
is_active=True
|
||||
).exists() or bool(obj.wp_url)
|
||||
|
||||
|
||||
class IndustrySectorSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for IndustrySector model."""
|
||||
|
||||
@@ -23,6 +23,8 @@ interface PageHeaderProps {
|
||||
icon: ReactNode;
|
||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||
};
|
||||
/** Completely hide site/sector selectors in app header */
|
||||
hideSelectors?: boolean;
|
||||
hideSiteSector?: boolean;
|
||||
navigation?: ReactNode; // Kept for backwards compat but not rendered
|
||||
workflowInsights?: any[]; // Kept for backwards compat but not rendered
|
||||
@@ -40,6 +42,7 @@ export default function PageHeader({
|
||||
onRefresh,
|
||||
className = "",
|
||||
badge,
|
||||
hideSelectors = false,
|
||||
hideSiteSector = false,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
@@ -54,11 +57,11 @@ export default function PageHeader({
|
||||
const parentModule = parent || breadcrumb;
|
||||
|
||||
// Update page context with title and badge info for AppHeader
|
||||
const pageInfoKey = useMemo(() => `${title}|${parentModule}`, [title, parentModule]);
|
||||
const pageInfoKey = useMemo(() => `${title}|${parentModule}|${hideSiteSector}|${hideSelectors}`, [title, parentModule, hideSiteSector, hideSelectors]);
|
||||
useEffect(() => {
|
||||
setPageInfo({ title, parent: parentModule, badge });
|
||||
setPageInfo({ title, parent: parentModule, badge, hideSelectors, hideSectorSelector: hideSiteSector });
|
||||
return () => setPageInfo(null);
|
||||
}, [pageInfoKey, badge?.color]);
|
||||
}, [pageInfoKey, badge?.color, hideSiteSector, hideSelectors]);
|
||||
|
||||
// Load sectors when active site changes
|
||||
useEffect(() => {
|
||||
|
||||
183
frontend/src/components/common/SingleSiteSelector.tsx
Normal file
183
frontend/src/components/common/SingleSiteSelector.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Single Site Selector
|
||||
* Site-only selector without "All Sites" option
|
||||
* For pages that require a specific site selection (Automation, Content Settings)
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Dropdown } from '../ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
||||
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import Button from '../ui/button/Button';
|
||||
|
||||
export default function SingleSiteSelector() {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||
|
||||
// Site switcher state
|
||||
const [sitesOpen, setSitesOpen] = useState(false);
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [sitesLoading, setSitesLoading] = useState(true);
|
||||
const siteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const noSitesAvailable = !sitesLoading && sites.length === 0;
|
||||
|
||||
// Load sites
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
refreshUser().catch((error) => {
|
||||
console.debug('SingleSiteSelector: Failed to refresh user (non-critical):', error);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
if (!activeSite) {
|
||||
loadActiveSite();
|
||||
}
|
||||
}, [user?.account?.id]);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
setSitesLoading(true);
|
||||
const response = await fetchSites();
|
||||
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||
setSites(activeSites);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load sites:', error);
|
||||
toast.error(`Failed to load sites: ${error.message}`);
|
||||
} finally {
|
||||
setSitesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteSelect = async (siteId: number) => {
|
||||
try {
|
||||
await apiSetActiveSite(siteId);
|
||||
const selectedSite = sites.find(s => s.id === siteId);
|
||||
if (selectedSite) {
|
||||
setActiveSite(selectedSite);
|
||||
toast.success(`Switched to "${selectedSite.name}"`);
|
||||
}
|
||||
setSitesOpen(false);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to switch site: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display text
|
||||
const getSiteDisplayText = () => {
|
||||
if (sitesLoading) return 'Loading...';
|
||||
return activeSite?.name || 'Select Site';
|
||||
};
|
||||
|
||||
// Check if a site is selected
|
||||
const isSiteSelected = (siteId: number) => {
|
||||
return activeSite?.id === siteId;
|
||||
};
|
||||
|
||||
const handleCreateSite = () => navigate('/sites');
|
||||
|
||||
if (sitesLoading && sites.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading sites...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (noSitesAvailable) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>No active sites yet.</span>
|
||||
<Button size="sm" variant="primary" onClick={handleCreateSite}>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
ref={siteButtonRef}
|
||||
onClick={() => setSitesOpen(!sitesOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
||||
aria-label="Switch site"
|
||||
disabled={sitesLoading || sites.length === 0}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{getSiteDisplayText()}
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sitesOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
isOpen={sitesOpen}
|
||||
onClose={() => setSitesOpen(false)}
|
||||
anchorRef={siteButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-64 p-2"
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<DropdownItem
|
||||
key={site.id}
|
||||
onItemClick={() => handleSiteSelect(site.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isSiteSelected(site.id)
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{site.name}</span>
|
||||
{isSiteSelected(site.id) && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Combined Site and Sector Selector Component
|
||||
* Displays both site switcher and sector selector side by side with accent colors
|
||||
*
|
||||
* Dashboard Mode: Shows "All Sites" option, uses callback for filtering
|
||||
* Module Mode: Standard site/sector selection
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -15,10 +18,19 @@ import Button from '../ui/button/Button';
|
||||
|
||||
interface SiteAndSectorSelectorProps {
|
||||
hideSectorSelector?: boolean;
|
||||
/** Dashboard mode: show "All Sites" option */
|
||||
showAllSitesOption?: boolean;
|
||||
/** Current site filter for dashboard mode ('all' or site id) */
|
||||
siteFilter?: 'all' | number;
|
||||
/** Callback when site filter changes in dashboard mode */
|
||||
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||
}
|
||||
|
||||
export default function SiteAndSectorSelector({
|
||||
hideSectorSelector = false,
|
||||
showAllSitesOption = false,
|
||||
siteFilter,
|
||||
onSiteFilterChange,
|
||||
}: SiteAndSectorSelectorProps) {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
@@ -67,7 +79,22 @@ export default function SiteAndSectorSelector({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteSelect = async (siteId: number) => {
|
||||
const handleSiteSelect = async (siteId: number | 'all') => {
|
||||
// Dashboard mode: use callback
|
||||
if (showAllSitesOption && onSiteFilterChange) {
|
||||
onSiteFilterChange(siteId);
|
||||
setSitesOpen(false);
|
||||
if (siteId !== 'all') {
|
||||
const selectedSite = sites.find(s => s.id === siteId);
|
||||
if (selectedSite) {
|
||||
setActiveSite(selectedSite);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Module mode: standard site switching
|
||||
if (siteId === 'all') return; // Should not happen in module mode
|
||||
try {
|
||||
await apiSetActiveSite(siteId);
|
||||
const selectedSite = sites.find(s => s.id === siteId);
|
||||
@@ -81,6 +108,24 @@ export default function SiteAndSectorSelector({
|
||||
}
|
||||
};
|
||||
|
||||
// Get display text based on mode
|
||||
const getSiteDisplayText = () => {
|
||||
if (sitesLoading) return 'Loading...';
|
||||
if (showAllSitesOption && siteFilter === 'all') return 'All Sites';
|
||||
if (showAllSitesOption && typeof siteFilter === 'number') {
|
||||
return sites.find(s => s.id === siteFilter)?.name || activeSite?.name || 'Select Site';
|
||||
}
|
||||
return activeSite?.name || 'Select Site';
|
||||
};
|
||||
|
||||
// Check if a site is selected
|
||||
const isSiteSelected = (siteId: number | 'all') => {
|
||||
if (showAllSitesOption) {
|
||||
return siteFilter === siteId;
|
||||
}
|
||||
return siteId !== 'all' && activeSite?.id === siteId;
|
||||
};
|
||||
|
||||
const handleSectorSelect = (sectorId: number | null) => {
|
||||
if (sectorId === null) {
|
||||
setActiveSector(null);
|
||||
@@ -141,7 +186,7 @@ export default function SiteAndSectorSelector({
|
||||
/>
|
||||
</svg>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{sitesLoading ? 'Loading...' : activeSite?.name || 'Select Site'}
|
||||
{getSiteDisplayText()}
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
@@ -166,18 +211,44 @@ export default function SiteAndSectorSelector({
|
||||
placement="bottom-left"
|
||||
className="w-64 p-2"
|
||||
>
|
||||
{/* All Sites option - only in dashboard mode */}
|
||||
{showAllSitesOption && (
|
||||
<DropdownItem
|
||||
onItemClick={() => handleSiteSelect('all')}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isSiteSelected('all')
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">All Sites</span>
|
||||
{isSiteSelected('all') && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{sites.map((site) => (
|
||||
<DropdownItem
|
||||
key={site.id}
|
||||
onItemClick={() => handleSiteSelect(site.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
activeSite?.id === site.id
|
||||
isSiteSelected(site.id)
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{site.name}</span>
|
||||
{activeSite?.id === site.id && (
|
||||
{isSiteSelected(site.id) && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
|
||||
import Switch from '../form/switch/Switch';
|
||||
import Button from '../ui/button/Button';
|
||||
import Badge from '../ui/badge/Badge';
|
||||
import SiteSetupChecklist from '../sites/SiteSetupChecklist';
|
||||
import { Site } from '../../services/api';
|
||||
|
||||
interface SiteCardProps {
|
||||
@@ -41,6 +42,12 @@ export default function SiteCard({
|
||||
|
||||
const statusText = getStatusText();
|
||||
|
||||
// Setup checklist state derived from site data
|
||||
const hasIndustry = !!site.industry || !!site.industry_name;
|
||||
const hasSectors = site.active_sectors_count > 0;
|
||||
const hasWordPressIntegration = site.has_integration ?? false;
|
||||
const hasKeywords = (site.keywords_count ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
|
||||
<div className="relative p-5 pb-9">
|
||||
@@ -75,6 +82,18 @@ export default function SiteCard({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Setup Checklist - Compact View */}
|
||||
<div className="mt-3">
|
||||
<SiteSetupChecklist
|
||||
siteId={site.id}
|
||||
siteName={site.name}
|
||||
hasIndustry={hasIndustry}
|
||||
hasSectors={hasSectors}
|
||||
hasWordPressIntegration={hasWordPressIntegration}
|
||||
hasKeywords={hasKeywords}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Status Text and Circle - Same row */}
|
||||
<div className="absolute top-5 right-5 flex items-center gap-2">
|
||||
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
|
||||
|
||||
238
frontend/src/components/common/SiteWithAllSitesSelector.tsx
Normal file
238
frontend/src/components/common/SiteWithAllSitesSelector.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Site Selector with "All Sites" Option
|
||||
* Site-only selector for dashboard/overview pages
|
||||
* No sector selection - just sites with "All Sites" as first option
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Dropdown } from '../ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
||||
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import Button from '../ui/button/Button';
|
||||
|
||||
interface SiteWithAllSitesSelectorProps {
|
||||
/** Current site filter ('all' or site id) */
|
||||
siteFilter?: 'all' | number;
|
||||
/** Callback when site filter changes */
|
||||
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||
}
|
||||
|
||||
export default function SiteWithAllSitesSelector({
|
||||
siteFilter = 'all',
|
||||
onSiteFilterChange,
|
||||
}: SiteWithAllSitesSelectorProps) {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||
|
||||
// Site switcher state
|
||||
const [sitesOpen, setSitesOpen] = useState(false);
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [sitesLoading, setSitesLoading] = useState(true);
|
||||
const siteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const noSitesAvailable = !sitesLoading && sites.length === 0;
|
||||
|
||||
// Load sites
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
refreshUser().catch((error) => {
|
||||
console.debug('SiteWithAllSitesSelector: Failed to refresh user (non-critical):', error);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
if (!activeSite) {
|
||||
loadActiveSite();
|
||||
}
|
||||
}, [user?.account?.id]);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
setSitesLoading(true);
|
||||
const response = await fetchSites();
|
||||
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||
setSites(activeSites);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load sites:', error);
|
||||
toast.error(`Failed to load sites: ${error.message}`);
|
||||
} finally {
|
||||
setSitesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteSelect = async (siteId: number | 'all') => {
|
||||
if (onSiteFilterChange) {
|
||||
onSiteFilterChange(siteId);
|
||||
setSitesOpen(false);
|
||||
if (siteId !== 'all') {
|
||||
const selectedSite = sites.find(s => s.id === siteId);
|
||||
if (selectedSite) {
|
||||
setActiveSite(selectedSite);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: standard site switching
|
||||
if (siteId === 'all') {
|
||||
setSitesOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiSetActiveSite(siteId);
|
||||
const selectedSite = sites.find(s => s.id === siteId);
|
||||
if (selectedSite) {
|
||||
setActiveSite(selectedSite);
|
||||
toast.success(`Switched to "${selectedSite.name}"`);
|
||||
}
|
||||
setSitesOpen(false);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to switch site: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display text
|
||||
const getSiteDisplayText = () => {
|
||||
if (sitesLoading) return 'Loading...';
|
||||
if (siteFilter === 'all') return 'All Sites';
|
||||
if (typeof siteFilter === 'number') {
|
||||
return sites.find(s => s.id === siteFilter)?.name || activeSite?.name || 'Select Site';
|
||||
}
|
||||
return activeSite?.name || 'All Sites';
|
||||
};
|
||||
|
||||
// Check if a site is selected
|
||||
const isSiteSelected = (siteId: number | 'all') => {
|
||||
return siteFilter === siteId;
|
||||
};
|
||||
|
||||
const handleCreateSite = () => navigate('/sites');
|
||||
|
||||
if (sitesLoading && sites.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading sites...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (noSitesAvailable) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>No active sites yet.</span>
|
||||
<Button size="sm" variant="primary" onClick={handleCreateSite}>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
ref={siteButtonRef}
|
||||
onClick={() => setSitesOpen(!sitesOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
||||
aria-label="Switch site"
|
||||
disabled={sitesLoading || sites.length === 0}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{getSiteDisplayText()}
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sitesOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
isOpen={sitesOpen}
|
||||
onClose={() => setSitesOpen(false)}
|
||||
anchorRef={siteButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-64 p-2"
|
||||
>
|
||||
{/* All Sites option */}
|
||||
<DropdownItem
|
||||
onItemClick={() => handleSiteSelect('all')}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isSiteSelected('all')
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">All Sites</span>
|
||||
{isSiteSelected('all') && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</DropdownItem>
|
||||
{sites.map((site) => (
|
||||
<DropdownItem
|
||||
key={site.id}
|
||||
onItemClick={() => handleSiteSelect(site.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isSiteSelected(site.id)
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{site.name}</span>
|
||||
{isSiteSelected(site.id) && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/dashboard/AIOperationsWidget.tsx
Normal file
159
frontend/src/components/dashboard/AIOperationsWidget.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* AIOperationsWidget - Shows AI operation statistics with time filter
|
||||
* Displays operation counts and credits used from CreditUsageLog
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
ChevronDownIcon,
|
||||
} from '../../icons';
|
||||
|
||||
export interface AIOperation {
|
||||
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||
count: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface AIOperationsData {
|
||||
period: '7d' | '30d' | '90d';
|
||||
operations: AIOperation[];
|
||||
totals: {
|
||||
count: number;
|
||||
credits: number;
|
||||
successRate: number;
|
||||
avgCreditsPerOp: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AIOperationsWidgetProps {
|
||||
data: AIOperationsData;
|
||||
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const operationConfig = {
|
||||
clustering: { label: 'Clustering', icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||
ideas: { label: 'Ideas', icon: BoltIcon, color: 'text-orange-600 dark:text-orange-400' },
|
||||
content: { label: 'Content', icon: FileTextIcon, color: 'text-green-600 dark:text-green-400' },
|
||||
images: { label: 'Images', icon: FileIcon, color: 'text-pink-600 dark:text-pink-400' },
|
||||
};
|
||||
|
||||
const periods = [
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: '90d', label: '90 days' },
|
||||
] as const;
|
||||
|
||||
export default function AIOperationsWidget({ data, onPeriodChange, loading }: AIOperationsWidgetProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const currentPeriod = periods.find(p => p.value === data.period) || periods[0];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header with Period Filter */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
AI Operations
|
||||
</h3>
|
||||
|
||||
{/* Period Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{currentPeriod.label}
|
||||
<ChevronDownIcon className={`w-3 h-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 mt-1 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
{periods.map((period) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => {
|
||||
onPeriodChange?.(period.value);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
data.period === period.value
|
||||
? 'text-brand-600 dark:text-brand-400 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operations Table */}
|
||||
<div className="space-y-0">
|
||||
{/* Table Header */}
|
||||
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="flex-1 font-medium">Operation</span>
|
||||
<span className="w-20 text-right font-medium">Count</span>
|
||||
<span className="w-24 text-right font-medium">Credits</span>
|
||||
</div>
|
||||
|
||||
{/* Operation Rows */}
|
||||
{data.operations.map((op) => {
|
||||
const config = operationConfig[op.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={op.type}
|
||||
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}`} />
|
||||
<span className="text-base text-gray-800 dark:text-gray-200">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{loading ? '—' : op.count.toLocaleString()}
|
||||
</span>
|
||||
<span className="w-24 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{loading ? '—' : op.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Totals Row */}
|
||||
<div className="flex items-center pt-2 font-semibold">
|
||||
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Total</span>
|
||||
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100">
|
||||
{loading ? '—' : data.totals.count.toLocaleString()}
|
||||
</span>
|
||||
<span className="w-24 text-base text-right text-gray-900 dark:text-gray-100">
|
||||
{loading ? '—' : data.totals.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Footer */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Success Rate: <span className="font-semibold text-green-600 dark:text-green-400">
|
||||
{loading ? '—' : `${data.totals.successRate}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Avg Credits/Op: <span className="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{loading ? '—' : data.totals.avgCreditsPerOp.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/dashboard/AutomationStatusWidget.tsx
Normal file
193
frontend/src/components/dashboard/AutomationStatusWidget.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* AutomationStatusWidget - Shows automation run status
|
||||
* Status indicator, schedule, last/next run info, configure/run now buttons
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import Button from '../ui/button/Button';
|
||||
import {
|
||||
PlayIcon,
|
||||
SettingsIcon,
|
||||
CheckCircleIcon,
|
||||
AlertIcon,
|
||||
ClockIcon,
|
||||
} from '../../icons';
|
||||
|
||||
export interface AutomationData {
|
||||
status: 'active' | 'paused' | 'failed' | 'not_configured';
|
||||
schedule?: string; // e.g., "Daily 9 AM"
|
||||
lastRun?: {
|
||||
timestamp: Date;
|
||||
clustered?: number;
|
||||
ideas?: number;
|
||||
content?: number;
|
||||
images?: number;
|
||||
success: boolean;
|
||||
};
|
||||
nextRun?: Date;
|
||||
siteId?: number;
|
||||
}
|
||||
|
||||
interface AutomationStatusWidgetProps {
|
||||
data: AutomationData;
|
||||
onRunNow?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
label: 'Active',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-500',
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
paused: {
|
||||
label: 'Paused',
|
||||
color: 'text-gray-700 dark:text-gray-300',
|
||||
bgColor: 'bg-gray-400',
|
||||
icon: ClockIcon,
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-500',
|
||||
icon: AlertIcon,
|
||||
},
|
||||
not_configured: {
|
||||
label: 'Not Configured',
|
||||
color: 'text-gray-600 dark:text-gray-400',
|
||||
bgColor: 'bg-gray-300',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
};
|
||||
|
||||
function formatDateTime(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
export default function AutomationStatusWidget({ data, onRunNow, loading }: AutomationStatusWidgetProps) {
|
||||
const config = statusConfig[data.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
Automation Status
|
||||
</h3>
|
||||
|
||||
{/* Status Row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`w-3 h-3 rounded-full ${config.bgColor} ${data.status === 'active' ? 'animate-pulse' : ''}`}></span>
|
||||
<span className={`text-base font-semibold ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
{data.schedule && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Schedule: {data.schedule}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Run Details */}
|
||||
{data.lastRun ? (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>Last Run: {formatDateTime(data.lastRun.timestamp)}</span>
|
||||
</div>
|
||||
<div className="pl-6 space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{data.lastRun.clustered !== undefined && data.lastRun.clustered > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">├─</span>
|
||||
<span>Clustered: {data.lastRun.clustered} keywords</span>
|
||||
</div>
|
||||
)}
|
||||
{data.lastRun.ideas !== undefined && data.lastRun.ideas > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">├─</span>
|
||||
<span>Ideas: {data.lastRun.ideas} generated</span>
|
||||
</div>
|
||||
)}
|
||||
{data.lastRun.content !== undefined && data.lastRun.content > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">├─</span>
|
||||
<span>Content: {data.lastRun.content} articles</span>
|
||||
</div>
|
||||
)}
|
||||
{data.lastRun.images !== undefined && data.lastRun.images > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">└─</span>
|
||||
<span>Images: {data.lastRun.images} created</span>
|
||||
</div>
|
||||
)}
|
||||
{!data.lastRun.clustered && !data.lastRun.ideas && !data.lastRun.content && !data.lastRun.images && (
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<span>└─</span>
|
||||
<span>No operations performed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : data.status !== 'not_configured' ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
No runs yet
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Next Run */}
|
||||
{data.nextRun && data.status === 'active' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Next Run: {formatDateTime(data.nextRun)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Not Configured State */}
|
||||
{data.status === 'not_configured' && (
|
||||
<div className="text-center py-4 mb-4">
|
||||
<SettingsIcon className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automation not configured
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Set up automated content generation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<Link to="/automation" className="flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
startIcon={<SettingsIcon className="w-4 h-4" />}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
{data.status !== 'not_configured' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onRunNow}
|
||||
disabled={loading}
|
||||
startIcon={<PlayIcon className="w-4 h-4" />}
|
||||
>
|
||||
Run Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/dashboard/ContentVelocityWidget.tsx
Normal file
115
frontend/src/components/dashboard/ContentVelocityWidget.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* ContentVelocityWidget - Shows content production rates
|
||||
* This Week / This Month / Total stats for articles, words, images
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrendingUpIcon, TrendingDownIcon } from '../../icons';
|
||||
|
||||
export interface ContentVelocityData {
|
||||
thisWeek: { articles: number; words: number; images: number };
|
||||
thisMonth: { articles: number; words: number; images: number };
|
||||
total: { articles: number; words: number; images: number };
|
||||
trend: number; // percentage vs previous period (positive = up, negative = down)
|
||||
}
|
||||
|
||||
interface ContentVelocityWidgetProps {
|
||||
data: ContentVelocityData;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ContentVelocityWidget({ data, loading }: ContentVelocityWidgetProps) {
|
||||
const isPositiveTrend = data.trend >= 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
Content Velocity
|
||||
</h3>
|
||||
|
||||
{/* Stats Table */}
|
||||
<div className="space-y-0">
|
||||
{/* Table Header */}
|
||||
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="flex-1"></span>
|
||||
<span className="w-20 text-right font-medium">Week</span>
|
||||
<span className="w-20 text-right font-medium">Month</span>
|
||||
<span className="w-20 text-right font-medium">Total</span>
|
||||
</div>
|
||||
|
||||
{/* Articles Row */}
|
||||
<div className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Articles</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||
{loading ? '—' : data.thisWeek.articles}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{loading ? '—' : data.thisMonth.articles}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||
{loading ? '—' : data.total.articles.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Words Row */}
|
||||
<div className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Words</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||
{loading ? '—' : formatNumber(data.thisWeek.words)}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{loading ? '—' : formatNumber(data.thisMonth.words)}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||
{loading ? '—' : formatNumber(data.total.words)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Images Row */}
|
||||
<div className="flex items-center py-2.5">
|
||||
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Images</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||
{loading ? '—' : data.thisWeek.images}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{loading ? '—' : data.thisMonth.images}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||
{loading ? '—' : data.total.images.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend Footer */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{isPositiveTrend ? (
|
||||
<TrendingUpIcon className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<TrendingDownIcon className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
<span className={`text-sm font-semibold ${isPositiveTrend ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{isPositiveTrend ? '+' : ''}{data.trend}% vs last week
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/analytics"
|
||||
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
View Analytics →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/dashboard/CreditAvailabilityWidget.tsx
Normal file
147
frontend/src/components/dashboard/CreditAvailabilityWidget.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CreditAvailabilityWidget - Shows available operations based on credit balance
|
||||
* Calculates how many operations can be performed with remaining credits
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
DollarLineIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface CreditAvailabilityWidgetProps {
|
||||
availableCredits: number;
|
||||
totalCredits: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Average credit costs per operation
|
||||
const OPERATION_COSTS = {
|
||||
clustering: { label: 'Clustering Runs', cost: 10, icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||
ideas: { label: 'Content Ideas', cost: 2, icon: BoltIcon, color: 'text-orange-600 dark:text-orange-400' },
|
||||
content: { label: 'Articles', cost: 50, icon: FileTextIcon, color: 'text-green-600 dark:text-green-400' },
|
||||
images: { label: 'Images', cost: 5, icon: FileIcon, color: 'text-pink-600 dark:text-pink-400' },
|
||||
};
|
||||
|
||||
export default function CreditAvailabilityWidget({
|
||||
availableCredits,
|
||||
totalCredits,
|
||||
loading = false
|
||||
}: CreditAvailabilityWidgetProps) {
|
||||
const usedCredits = totalCredits - availableCredits;
|
||||
const usagePercent = totalCredits > 0 ? Math.round((usedCredits / totalCredits) * 100) : 0;
|
||||
|
||||
// Calculate available operations
|
||||
const availableOps = Object.entries(OPERATION_COSTS).map(([key, config]) => ({
|
||||
type: key,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
cost: config.cost,
|
||||
available: Math.floor(availableCredits / config.cost),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
Credit Availability
|
||||
</h3>
|
||||
<Link
|
||||
to="/billing/credits"
|
||||
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Add Credits →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Credits Balance */}
|
||||
<div className="bg-gradient-to-r from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Available Credits</span>
|
||||
<span className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{loading ? '—' : availableCredits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-white dark:bg-gray-800 rounded-full h-2 mb-1">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
usagePercent > 90 ? 'bg-red-500' : usagePercent > 75 ? 'bg-amber-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.max(100 - usagePercent, 0)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used (${usagePercent}%)` : 'No credits allocated'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Operations */}
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-2">
|
||||
You can run:
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-gray-500">Loading...</p>
|
||||
</div>
|
||||
) : availableCredits === 0 ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No credits available</p>
|
||||
<Link
|
||||
to="/billing/credits"
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Purchase credits to continue
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
availableOps.map((op) => {
|
||||
const Icon = op.icon;
|
||||
return (
|
||||
<div
|
||||
key={op.type}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className={`flex-shrink-0 ${op.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{op.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{op.cost} credits each
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-lg font-bold ${
|
||||
op.available > 10 ? 'text-green-600 dark:text-green-400' :
|
||||
op.available > 0 ? 'text-amber-600 dark:text-amber-400' :
|
||||
'text-gray-400 dark:text-gray-600'
|
||||
}`}>
|
||||
{op.available === 0 ? '—' : op.available > 999 ? '999+' : op.available}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning if low */}
|
||||
{!loading && availableCredits > 0 && availableCredits < 100 && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-2 text-amber-600 dark:text-amber-400">
|
||||
<DollarLineIcon className="w-4 h-4 mt-0.5" />
|
||||
<p className="text-xs">
|
||||
You're running low on credits. Consider purchasing more to avoid interruptions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
163
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* NeedsAttentionBar - Collapsible alert bar for items requiring user action
|
||||
* Shows pending reviews, sync failures, setup incomplete, automation failures
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CheckCircleIcon,
|
||||
CloseIcon,
|
||||
} from '../../icons';
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
type: 'pending_review' | 'sync_failed' | 'setup_incomplete' | 'automation_failed' | 'credits_low';
|
||||
title: string;
|
||||
description: string;
|
||||
count?: number;
|
||||
actionLabel: string;
|
||||
actionHref?: string;
|
||||
onAction?: () => void;
|
||||
secondaryActionLabel?: string;
|
||||
secondaryActionHref?: string;
|
||||
onSecondaryAction?: () => void;
|
||||
}
|
||||
|
||||
interface NeedsAttentionBarProps {
|
||||
items: AttentionItem[];
|
||||
onDismiss?: (id: string) => void;
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
pending_review: {
|
||||
icon: CheckCircleIcon,
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
iconColor: 'text-amber-500',
|
||||
titleColor: 'text-amber-800 dark:text-amber-200',
|
||||
},
|
||||
sync_failed: {
|
||||
icon: AlertIcon,
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
iconColor: 'text-red-500',
|
||||
titleColor: 'text-red-800 dark:text-red-200',
|
||||
},
|
||||
setup_incomplete: {
|
||||
icon: AlertIcon,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
iconColor: 'text-blue-500',
|
||||
titleColor: 'text-blue-800 dark:text-blue-200',
|
||||
},
|
||||
automation_failed: {
|
||||
icon: AlertIcon,
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
iconColor: 'text-red-500',
|
||||
titleColor: 'text-red-800 dark:text-red-200',
|
||||
},
|
||||
credits_low: {
|
||||
icon: AlertIcon,
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
iconColor: 'text-orange-500',
|
||||
titleColor: 'text-orange-800 dark:text-orange-200',
|
||||
},
|
||||
};
|
||||
|
||||
export default function NeedsAttentionBar({ items, onDismiss }: NeedsAttentionBarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="w-full flex items-center justify-between px-5 py-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-t-xl hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-base font-semibold text-amber-800 dark:text-amber-200">
|
||||
Needs Attention ({items.length})
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<ChevronUpIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="border border-t-0 border-amber-200 dark:border-amber-800 rounded-b-xl bg-white dark:bg-gray-900 p-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{items.map((item) => {
|
||||
const config = typeConfig[item.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-lg border ${config.bgColor} ${config.borderColor} min-w-[220px] flex-1 max-w-[380px]`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${config.iconColor}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-base font-semibold ${config.titleColor}`}>
|
||||
{item.count ? `${item.count} ${item.title}` : item.title}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 line-clamp-1">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{item.actionHref ? (
|
||||
<Link
|
||||
to={item.actionHref}
|
||||
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
{item.actionLabel} →
|
||||
</Link>
|
||||
) : item.onAction ? (
|
||||
<button
|
||||
onClick={item.onAction}
|
||||
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
{item.actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{item.secondaryActionHref && (
|
||||
<Link
|
||||
to={item.secondaryActionHref}
|
||||
className="text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
{item.secondaryActionLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={() => onDismiss(item.id)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/dashboard/OperationsCostsWidget.tsx
Normal file
143
frontend/src/components/dashboard/OperationsCostsWidget.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* OperationsCostsWidget - Shows individual AI operations with counts and credit costs
|
||||
* Displays recent operations statistics for the site
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface OperationStat {
|
||||
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||
count: number;
|
||||
creditsUsed: number;
|
||||
avgCreditsPerOp: number;
|
||||
}
|
||||
|
||||
interface OperationsCostsWidgetProps {
|
||||
operations: OperationStat[];
|
||||
period?: '7d' | '30d' | 'total';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const operationConfig = {
|
||||
clustering: {
|
||||
label: 'Clustering',
|
||||
icon: GroupIcon,
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
href: '/planner/clusters',
|
||||
},
|
||||
ideas: {
|
||||
label: 'Ideas',
|
||||
icon: BoltIcon,
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
href: '/planner/ideas',
|
||||
},
|
||||
content: {
|
||||
label: 'Content',
|
||||
icon: FileTextIcon,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
href: '/writer/content',
|
||||
},
|
||||
images: {
|
||||
label: 'Images',
|
||||
icon: FileIcon,
|
||||
color: 'text-pink-600 dark:text-pink-400',
|
||||
href: '/writer/images',
|
||||
},
|
||||
};
|
||||
|
||||
export default function OperationsCostsWidget({
|
||||
operations,
|
||||
period = '7d',
|
||||
loading = false
|
||||
}: OperationsCostsWidgetProps) {
|
||||
const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'All Time';
|
||||
|
||||
const totalOps = operations.reduce((sum, op) => sum + op.count, 0);
|
||||
const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
AI Operations
|
||||
</h3>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Operations List */}
|
||||
<div className="space-y-0">
|
||||
{/* Table Header */}
|
||||
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="flex-1 font-medium">Operation</span>
|
||||
<span className="w-16 text-right font-medium">Count</span>
|
||||
<span className="w-20 text-right font-medium">Credits</span>
|
||||
<span className="w-16 text-right font-medium">Avg</span>
|
||||
</div>
|
||||
|
||||
{/* Operation Rows */}
|
||||
{loading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">Loading...</p>
|
||||
</div>
|
||||
) : operations.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No operations yet</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Start by adding keywords and clustering them
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{operations.map((op) => {
|
||||
const config = operationConfig[op.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={op.type}
|
||||
to={config.href}
|
||||
className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors rounded px-1 -mx-1"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
<span className="text-base text-gray-800 dark:text-gray-200">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-16 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||
{op.count}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||
{op.creditsUsed}
|
||||
</span>
|
||||
<span className="w-16 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||
{op.avgCreditsPerOp.toFixed(1)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Totals Row */}
|
||||
<div className="flex items-center pt-2.5 font-semibold border-t border-gray-200 dark:border-gray-700 mt-1">
|
||||
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Total</span>
|
||||
<span className="w-16 text-base text-right text-gray-900 dark:text-gray-100">
|
||||
{totalOps}
|
||||
</span>
|
||||
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100">
|
||||
{totalCredits}
|
||||
</span>
|
||||
<span className="w-16"></span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
frontend/src/components/dashboard/QuickActionsWidget.tsx
Normal file
255
frontend/src/components/dashboard/QuickActionsWidget.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* QuickActionsWidget - Workflow guide with explainer text
|
||||
* Full-width layout with steps in 3 columns (1-3, 4-6, 7-8)
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Button from '../ui/button/Button';
|
||||
import {
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
CheckCircleIcon,
|
||||
PaperPlaneIcon,
|
||||
HelpCircleIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface QuickActionsWidgetProps {
|
||||
onAddKeywords?: () => void;
|
||||
}
|
||||
|
||||
const workflowSteps = [
|
||||
{
|
||||
num: 1,
|
||||
icon: ListIcon,
|
||||
title: 'Add Keywords',
|
||||
description: 'Import your target keywords manually or from CSV',
|
||||
href: '/planner/keyword-opportunities',
|
||||
actionLabel: 'Add',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
icon: GroupIcon,
|
||||
title: 'Auto Cluster',
|
||||
description: 'AI groups related keywords into content clusters',
|
||||
href: '/planner/clusters',
|
||||
actionLabel: 'Cluster',
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
icon: BoltIcon,
|
||||
title: 'Generate Ideas',
|
||||
description: 'Create content ideas from your keyword clusters',
|
||||
href: '/planner/ideas',
|
||||
actionLabel: 'Ideas',
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
icon: CheckCircleIcon,
|
||||
title: 'Create Tasks',
|
||||
description: 'Convert approved ideas into content tasks',
|
||||
href: '/writer/tasks',
|
||||
actionLabel: 'Tasks',
|
||||
color: 'text-indigo-600 dark:text-indigo-400',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
icon: FileTextIcon,
|
||||
title: 'Generate Content',
|
||||
description: 'AI writes SEO-optimized articles from tasks',
|
||||
href: '/writer/content',
|
||||
actionLabel: 'Write',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
icon: FileIcon,
|
||||
title: 'Generate Images',
|
||||
description: 'Create featured images and media for articles',
|
||||
href: '/writer/images',
|
||||
actionLabel: 'Images',
|
||||
color: 'text-pink-600 dark:text-pink-400',
|
||||
},
|
||||
{
|
||||
num: 7,
|
||||
icon: CheckCircleIcon,
|
||||
title: 'Review & Approve',
|
||||
description: 'Quality check and approve generated content',
|
||||
href: '/writer/review',
|
||||
actionLabel: 'Review',
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
num: 8,
|
||||
icon: PaperPlaneIcon,
|
||||
title: 'Publish to WP',
|
||||
description: 'Push approved content to your WordPress site',
|
||||
href: '/writer/published',
|
||||
actionLabel: 'Publish',
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
];
|
||||
|
||||
export default function QuickActionsWidget({ onAddKeywords }: QuickActionsWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
Workflow Guide
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
startIcon={<HelpCircleIcon className="w-4 h-4" />}
|
||||
onClick={() => navigate('/help')}
|
||||
>
|
||||
Full Help Guide
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 3-Column Grid: Steps 1-3, 4-6, 7-8 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Column 1: Steps 1-3 */}
|
||||
<div className="space-y-2.5">
|
||||
{workflowSteps.slice(0, 3).map((step) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.num}
|
||||
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">
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
onClick={() => navigate(step.href)}
|
||||
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{step.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Column 2: Steps 4-6 */}
|
||||
<div className="space-y-2.5">
|
||||
{workflowSteps.slice(3, 6).map((step) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.num}
|
||||
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">
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
onClick={() => navigate(step.href)}
|
||||
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{step.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Column 3: Steps 7-8 */}
|
||||
<div className="space-y-2.5">
|
||||
{workflowSteps.slice(6, 8).map((step) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.num}
|
||||
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">
|
||||
{step.num}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${step.color}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
onClick={() => navigate(step.href)}
|
||||
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{step.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
137
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* RecentActivityWidget - Shows last 5 significant operations
|
||||
* Displays AI task completions, publishing events, etc.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
PaperPlaneIcon,
|
||||
ListIcon,
|
||||
AlertIcon,
|
||||
CheckCircleIcon,
|
||||
} from '../../icons';
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
type: 'clustering' | 'ideas' | 'content' | 'images' | 'published' | 'keywords' | 'error' | 'sync';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
href?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
interface RecentActivityWidgetProps {
|
||||
activities: ActivityItem[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const activityConfig = {
|
||||
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-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/40' },
|
||||
content: { icon: FileTextIcon, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/40' },
|
||||
images: { icon: FileIcon, color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/40' },
|
||||
published: { icon: PaperPlaneIcon, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/40' },
|
||||
keywords: { icon: ListIcon, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/40' },
|
||||
error: { icon: AlertIcon, color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/40' },
|
||||
sync: { icon: CheckCircleIcon, color: 'text-teal-600 dark:text-teal-400', bgColor: 'bg-teal-100 dark:bg-teal-900/40' },
|
||||
};
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function RecentActivityWidget({ activities, loading }: RecentActivityWidgetProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
Recent Activity
|
||||
</h3>
|
||||
|
||||
{/* Activity List */}
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 animate-pulse">
|
||||
<div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-3/4 bg-gray-100 dark:bg-gray-800 rounded mb-2"></div>
|
||||
<div className="h-3 w-1/4 bg-gray-100 dark:bg-gray-800 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-base text-gray-600 dark:text-gray-400">No recent activity</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-1">
|
||||
AI operations will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
activities.slice(0, 5).map((activity) => {
|
||||
const config = activityConfig[activity.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base text-gray-800 dark:text-gray-200 line-clamp-1">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return activity.href ? (
|
||||
<Link
|
||||
key={activity.id}
|
||||
to={activity.href}
|
||||
className="block hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg p-1 -m-1 transition-colors"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={activity.id} className="p-1 -m-1">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All Link */}
|
||||
{activities.length > 0 && (
|
||||
<Link
|
||||
to="/account/activity"
|
||||
className="block mt-3 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 text-center"
|
||||
>
|
||||
View All Activity →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/dashboard/SiteConfigWidget.tsx
Normal file
148
frontend/src/components/dashboard/SiteConfigWidget.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* SiteConfigWidget - Shows site configuration status
|
||||
* Displays what's configured from site settings
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
AlertIcon,
|
||||
GridIcon,
|
||||
PlugInIcon,
|
||||
UserIcon,
|
||||
FileTextIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface SiteConfigWidgetProps {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
hasIndustry: boolean;
|
||||
hasSectors: boolean;
|
||||
sectorsCount?: number;
|
||||
hasWordPress: boolean;
|
||||
hasKeywords: boolean;
|
||||
keywordsCount?: number;
|
||||
hasAuthorProfiles: boolean;
|
||||
authorProfilesCount?: number;
|
||||
}
|
||||
|
||||
export default function SiteConfigWidget({
|
||||
siteId,
|
||||
siteName,
|
||||
hasIndustry,
|
||||
hasSectors,
|
||||
sectorsCount = 0,
|
||||
hasWordPress,
|
||||
hasKeywords,
|
||||
keywordsCount = 0,
|
||||
hasAuthorProfiles,
|
||||
authorProfilesCount = 0,
|
||||
}: SiteConfigWidgetProps) {
|
||||
const configItems = [
|
||||
{
|
||||
label: 'Industry & Sectors',
|
||||
configured: hasIndustry && hasSectors,
|
||||
detail: hasSectors ? `${sectorsCount} sector${sectorsCount !== 1 ? 's' : ''}` : 'Not configured',
|
||||
icon: GridIcon,
|
||||
href: `/sites/${siteId}/settings?tab=industry`,
|
||||
},
|
||||
{
|
||||
label: 'WordPress Integration',
|
||||
configured: hasWordPress,
|
||||
detail: hasWordPress ? 'Connected' : 'Not connected',
|
||||
icon: PlugInIcon,
|
||||
href: `/sites/${siteId}/settings?tab=integrations`,
|
||||
},
|
||||
{
|
||||
label: 'Keywords',
|
||||
configured: hasKeywords,
|
||||
detail: hasKeywords ? `${keywordsCount} keyword${keywordsCount !== 1 ? 's' : ''}` : 'No keywords',
|
||||
icon: FileTextIcon,
|
||||
href: `/planner/keywords?site=${siteId}`,
|
||||
},
|
||||
{
|
||||
label: 'Author Profiles',
|
||||
configured: hasAuthorProfiles,
|
||||
detail: hasAuthorProfiles ? `${authorProfilesCount} profile${authorProfilesCount !== 1 ? 's' : ''}` : 'No profiles',
|
||||
icon: UserIcon,
|
||||
href: `/sites/${siteId}/settings?tab=authors`,
|
||||
},
|
||||
];
|
||||
|
||||
const configuredCount = configItems.filter(item => item.configured).length;
|
||||
const totalCount = configItems.length;
|
||||
const completionPercent = Math.round((configuredCount / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
Site Configuration
|
||||
</h3>
|
||||
<span className={`text-lg font-bold ${completionPercent === 100 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{configuredCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Config Items */}
|
||||
<div className="space-y-3">
|
||||
{configItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.href}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
item.configured
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
item.configured
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className={`text-xs ${
|
||||
item.configured
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
}`}>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
{item.configured ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Completion Progress */}
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">Setup Progress</span>
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-200">{completionPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
completionPercent === 100 ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`}
|
||||
style={{ width: `${completionPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
385
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* ThreeWidgetFooter - 3-Column Layout for Table Page Footers
|
||||
*
|
||||
* 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 │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
/** Submodule color type - matches headerMetrics accentColor */
|
||||
export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
|
||||
|
||||
/** Widget 1: Page Progress - metrics in 2x2 grid + progress bar + hint */
|
||||
export interface PageProgressWidget {
|
||||
title: string;
|
||||
metrics: Array<{ label: string; value: string | number; percentage?: string }>;
|
||||
progress: { value: number; label: string; color?: SubmoduleColor };
|
||||
hint?: string;
|
||||
/** The submodule's accent color - progress bar uses this */
|
||||
submoduleColor?: SubmoduleColor;
|
||||
}
|
||||
|
||||
/** Widget 2: Module Stats - Pipeline flow with arrows and progress bars */
|
||||
export interface ModulePipelineRow {
|
||||
fromLabel: string;
|
||||
fromValue: number;
|
||||
fromHref?: string;
|
||||
actionLabel: string;
|
||||
toLabel: string;
|
||||
toValue: number;
|
||||
toHref?: string;
|
||||
progress: number; // 0-100
|
||||
/** Color for this pipeline row's progress bar */
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
|
||||
export interface ModuleStatsWidget {
|
||||
title: string;
|
||||
pipeline: ModulePipelineRow[];
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CompletionWidget {
|
||||
title: string;
|
||||
plannerItems: CompletionItem[];
|
||||
writerItems: CompletionItem[];
|
||||
creditsUsed?: number;
|
||||
operationsCount?: number;
|
||||
analyticsHref?: string;
|
||||
}
|
||||
|
||||
/** Main component props */
|
||||
export interface ThreeWidgetFooterProps {
|
||||
pageProgress: PageProgressWidget;
|
||||
moduleStats: ModuleStatsWidget;
|
||||
completion: CompletionWidget;
|
||||
submoduleColor?: SubmoduleColor;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLOR UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
const getProgressBarStyle = (color: SubmoduleColor = 'blue'): React.CSSProperties => {
|
||||
const colorMap: Record<SubmoduleColor, string> = {
|
||||
blue: 'var(--color-primary)',
|
||||
green: 'var(--color-success)',
|
||||
amber: 'var(--color-warning)',
|
||||
purple: 'var(--color-purple)',
|
||||
};
|
||||
return { backgroundColor: colorMap[color] };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 1: PAGE PROGRESS
|
||||
// ============================================================================
|
||||
|
||||
function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PageProgressWidget; submoduleColor?: SubmoduleColor }) {
|
||||
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">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* 2x2 Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||||
{widget.metrics.slice(0, 4).map((metric, idx) => (
|
||||
<div key={idx} className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-[color:var(--color-text-dim)] dark:text-gray-400">{metric.label}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
|
||||
</span>
|
||||
{metric.percentage && (
|
||||
<span className="text-xs text-[color:var(--color-text-dim)] dark:text-gray-400">({metric.percentage})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(progressColor),
|
||||
width: `${Math.min(100, Math.max(0, widget.progress.value))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{widget.progress.label}</span>
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white">{widget.progress.value}%</span>
|
||||
</div>
|
||||
</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">
|
||||
<LightBulbIcon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-warning)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}>{widget.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 2: MODULE STATS
|
||||
// ============================================================================
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Pipeline Rows */}
|
||||
<div className="space-y-4 mb-4">
|
||||
{widget.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">
|
||||
{row.fromHref ? (
|
||||
<Link
|
||||
to={row.fromHref}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
{row.fromLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.fromLabel}</span>
|
||||
)}
|
||||
<span className="text-lg font-bold tabular-nums" style={{ color: 'var(--color-primary)' }}>
|
||||
{row.fromValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow icon */}
|
||||
<ChevronRightIcon
|
||||
className="w-6 h-6 flex-shrink-0 mx-2"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
/>
|
||||
|
||||
{/* To side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{row.toHref ? (
|
||||
<Link
|
||||
to={row.toHref}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
{row.toLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.toLabel}</span>
|
||||
)}
|
||||
<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={{
|
||||
...getProgressBarStyle(row.color || 'blue'),
|
||||
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">
|
||||
{widget.links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
to={link.href}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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" style={{ color: 'var(--color-primary)' }}>
|
||||
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" style={{ color: 'var(--color-success)' }}>
|
||||
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"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
View Full Analytics
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function ThreeWidgetFooter({
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
submoduleColor = 'blue',
|
||||
className = '',
|
||||
}: ThreeWidgetFooterProps) {
|
||||
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">
|
||||
<PageProgressCard widget={pageProgress} submoduleColor={submoduleColor} />
|
||||
<ModuleStatsCard widget={moduleStats} />
|
||||
<CompletionCard widget={completion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export sub-components for flexibility
|
||||
export { PageProgressCard, ModuleStatsCard, CompletionCard };
|
||||
112
frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
Normal file
112
frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* WorkflowPipelineWidget - Visual flow showing content creation pipeline
|
||||
* Sites → Keywords → Clusters → Ideas → Tasks → Drafts → Published
|
||||
* Balanced single-row layout with filled arrow connectors
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ProgressBar } from '../ui/progress';
|
||||
import {
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
CheckCircleIcon,
|
||||
FileTextIcon,
|
||||
PaperPlaneIcon,
|
||||
ChevronRightIcon,
|
||||
} from '../../icons';
|
||||
|
||||
export interface PipelineData {
|
||||
sites: number;
|
||||
keywords: number;
|
||||
clusters: number;
|
||||
ideas: number;
|
||||
tasks: number;
|
||||
drafts: number;
|
||||
published: number;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
interface WorkflowPipelineWidgetProps {
|
||||
data: PipelineData;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{ key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', color: 'text-blue-600 dark:text-blue-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-orange-600 dark:text-orange-400' },
|
||||
{ key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', color: 'text-indigo-600 dark:text-indigo-400' },
|
||||
{ key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', color: 'text-green-600 dark:text-green-400' },
|
||||
{ key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
] as const;
|
||||
|
||||
// Small filled arrow triangle component
|
||||
function ArrowTip() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-4 h-4 mx-1">
|
||||
<svg viewBox="0 0 8 12" className="w-2.5 h-3.5 fill-brand-500 dark:fill-brand-400">
|
||||
<path d="M0 0 L8 6 L0 12 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowPipelineWidget({ data, loading }: WorkflowPipelineWidgetProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
Workflow Pipeline
|
||||
</h3>
|
||||
<span className="text-3xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{data.completionPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow - Single Balanced Row */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const count = data[stage.key as keyof PipelineData];
|
||||
|
||||
return (
|
||||
<div key={stage.key} className="flex items-center">
|
||||
<Link
|
||||
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>
|
||||
<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}`}>
|
||||
{loading ? '—' : typeof count === 'number' ? count.toLocaleString() : count}
|
||||
</span>
|
||||
</Link>
|
||||
{index < stages.length - 1 && <ArrowTip />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-4">
|
||||
<ProgressBar
|
||||
value={data.completionPercentage}
|
||||
size="md"
|
||||
color="primary"
|
||||
className="h-2.5"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
|
||||
{data.completionPercentage}% of keywords converted to published content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,79 @@
|
||||
/**
|
||||
* NotificationDropdown - Dynamic notification dropdown using store
|
||||
* Shows AI task completions, system events, and other notifications
|
||||
*/
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useNotificationStore,
|
||||
formatNotificationTime,
|
||||
getNotificationColors,
|
||||
NotificationType
|
||||
} from "../../store/notificationStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
AlertIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
GroupIcon,
|
||||
} from "../../icons";
|
||||
|
||||
// Icon map for different notification categories/functions
|
||||
const getNotificationIcon = (category: string, functionName?: string): React.ReactNode => {
|
||||
if (functionName) {
|
||||
switch (functionName) {
|
||||
case 'auto_cluster':
|
||||
return <GroupIcon className="w-5 h-5" />;
|
||||
case 'generate_ideas':
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
case 'generate_content':
|
||||
return <FileTextIcon className="w-5 h-5" />;
|
||||
case 'generate_images':
|
||||
case 'generate_image_prompts':
|
||||
return <FileIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case 'ai_task':
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
case 'system':
|
||||
return <AlertIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <CheckCircleIcon className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: NotificationType): React.ReactNode => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-4 h-4" />;
|
||||
case 'error':
|
||||
case 'warning':
|
||||
return <AlertIcon className="w-4 h-4" />;
|
||||
default:
|
||||
return <BoltIcon className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifying, setNotifying] = useState(true);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification
|
||||
} = useNotificationStore();
|
||||
|
||||
function toggleDropdown() {
|
||||
setIsOpen(!isOpen);
|
||||
@@ -18,22 +85,31 @@ export default function NotificationDropdown() {
|
||||
|
||||
const handleClick = () => {
|
||||
toggleDropdown();
|
||||
setNotifying(false);
|
||||
};
|
||||
|
||||
const handleNotificationClick = (id: string, href?: string) => {
|
||||
markAsRead(id);
|
||||
closeDropdown();
|
||||
if (href) {
|
||||
navigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
onClick={handleClick}
|
||||
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${
|
||||
!notifying ? "hidden" : "flex"
|
||||
}`}
|
||||
>
|
||||
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
||||
</span>
|
||||
{/* Notification badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
@@ -49,335 +125,143 @@ export default function NotificationDropdown() {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
isOpen={isOpen}
|
||||
onClose={closeDropdown}
|
||||
anchorRef={buttonRef}
|
||||
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-right"
|
||||
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
Notification
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({unreadCount} new)
|
||||
</span>
|
||||
)}
|
||||
</h5>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
aria-label="Close notifications"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar">
|
||||
{/* Example notification items */}
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-02.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
{/* Notification List */}
|
||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
|
||||
{notifications.length === 0 ? (
|
||||
<li className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<BoltIcon className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No notifications yet
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
AI task completions will appear here
|
||||
</p>
|
||||
</li>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
const colors = getNotificationColors(notification.type);
|
||||
const icon = getNotificationIcon(
|
||||
notification.category,
|
||||
notification.metadata?.functionName
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={notification.id}>
|
||||
<DropdownItem
|
||||
onItemClick={() => handleNotificationClick(
|
||||
notification.id,
|
||||
notification.actionHref
|
||||
)}
|
||||
className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-white/5 ${
|
||||
!notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
|
||||
<span className={colors.icon}>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400 space-x-1">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Terry Franci
|
||||
</span>
|
||||
<span> requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
{/* Content */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
!notification.read
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</span>
|
||||
{!notification.read && (
|
||||
<span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
|
||||
{notification.message}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>5 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-03.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Alena Franci
|
||||
</span>
|
||||
<span>requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>8 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-04.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Jocelyn Kenter
|
||||
</span>
|
||||
<span> requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>15 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
to="/"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-05.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 space-x-1 block text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Brandon Philips
|
||||
</span>
|
||||
<span>requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>1 hr ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
onItemClick={closeDropdown}
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-02.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Terry Franci
|
||||
</span>
|
||||
<span> requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>5 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-03.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Alena Franci
|
||||
</span>
|
||||
<span> requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>8 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-04.jpg"
|
||||
alt="User"
|
||||
className="w-full overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Jocelyn Kenter
|
||||
</span>
|
||||
<span> requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>15 min ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
src="/images/user/user-05.jpg"
|
||||
alt="User"
|
||||
className="overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Brandon Philips
|
||||
</span>
|
||||
<span>requests permission to change</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
||||
Project - Nganter App
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
||||
<span>Project</span>
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
||||
<span>1 hr ago</span>
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
{/* Add more items as needed */}
|
||||
<span className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNotificationTime(notification.timestamp)}
|
||||
</span>
|
||||
{notification.actionLabel && notification.actionHref && (
|
||||
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||
{notification.actionLabel} →
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
<Link
|
||||
to="/"
|
||||
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
View All Notifications
|
||||
</Link>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={closeDropdown}
|
||||
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
View All Notifications
|
||||
</Link>
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
268
frontend/src/components/header/NotificationDropdownNew.tsx
Normal file
268
frontend/src/components/header/NotificationDropdownNew.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* NotificationDropdown - Dynamic notification dropdown using store
|
||||
* Shows AI task completions, system events, and other notifications
|
||||
*/
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
import {
|
||||
useNotificationStore,
|
||||
formatNotificationTime,
|
||||
getNotificationColors,
|
||||
NotificationType
|
||||
} from "../../store/notificationStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
AlertIcon,
|
||||
BoltIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
GroupIcon,
|
||||
} from "../../icons";
|
||||
|
||||
// Icon map for different notification categories/functions
|
||||
const getNotificationIcon = (category: string, functionName?: string): React.ReactNode => {
|
||||
if (functionName) {
|
||||
switch (functionName) {
|
||||
case 'auto_cluster':
|
||||
return <GroupIcon className="w-5 h-5" />;
|
||||
case 'generate_ideas':
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
case 'generate_content':
|
||||
return <FileTextIcon className="w-5 h-5" />;
|
||||
case 'generate_images':
|
||||
case 'generate_image_prompts':
|
||||
return <FileIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case 'ai_task':
|
||||
return <BoltIcon className="w-5 h-5" />;
|
||||
case 'system':
|
||||
return <AlertIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <CheckCircleIcon className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: NotificationType): React.ReactNode => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-4 h-4" />;
|
||||
case 'error':
|
||||
case 'warning':
|
||||
return <AlertIcon className="w-4 h-4" />;
|
||||
default:
|
||||
return <BoltIcon className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification
|
||||
} = useNotificationStore();
|
||||
|
||||
function toggleDropdown() {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleNotificationClick = (id: string, href?: string) => {
|
||||
markAsRead(id);
|
||||
closeDropdown();
|
||||
if (href) {
|
||||
navigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
onClick={handleClick}
|
||||
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
{/* Notification badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.75 2.29248C10.75 1.87827 10.4143 1.54248 10 1.54248C9.58583 1.54248 9.25004 1.87827 9.25004 2.29248V2.83613C6.08266 3.20733 3.62504 5.9004 3.62504 9.16748V14.4591H3.33337C2.91916 14.4591 2.58337 14.7949 2.58337 15.2091C2.58337 15.6234 2.91916 15.9591 3.33337 15.9591H4.37504H15.625H16.6667C17.0809 15.9591 17.4167 15.6234 17.4167 15.2091C17.4167 14.7949 17.0809 14.4591 16.6667 14.4591H16.375V9.16748C16.375 5.9004 13.9174 3.20733 10.75 2.83613V2.29248ZM14.875 14.4591V9.16748C14.875 6.47509 12.6924 4.29248 10 4.29248C7.30765 4.29248 5.12504 6.47509 5.12504 9.16748V14.4591H14.875ZM8.00004 17.7085C8.00004 18.1228 8.33583 18.4585 8.75004 18.4585H11.25C11.6643 18.4585 12 18.1228 12 17.7085C12 17.2943 11.6643 16.9585 11.25 16.9585H8.75004C8.33583 16.9585 8.00004 17.2943 8.00004 17.7085Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
isOpen={isOpen}
|
||||
onClose={closeDropdown}
|
||||
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-right"
|
||||
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({unreadCount} new)
|
||||
</span>
|
||||
)}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
aria-label="Close notifications"
|
||||
>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
|
||||
{notifications.length === 0 ? (
|
||||
<li className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<BoltIcon className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No notifications yet
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
AI task completions will appear here
|
||||
</p>
|
||||
</li>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
const colors = getNotificationColors(notification.type);
|
||||
const icon = getNotificationIcon(
|
||||
notification.category,
|
||||
notification.metadata?.functionName
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={notification.id}>
|
||||
<DropdownItem
|
||||
onItemClick={() => handleNotificationClick(
|
||||
notification.id,
|
||||
notification.actionHref
|
||||
)}
|
||||
className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-white/5 ${
|
||||
!notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
|
||||
<span className={colors.icon}>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
!notification.read
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</span>
|
||||
{!notification.read && (
|
||||
<span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
|
||||
{notification.message}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNotificationTime(notification.timestamp)}
|
||||
</span>
|
||||
{notification.actionLabel && notification.actionHref && (
|
||||
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||
{notification.actionLabel} →
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={closeDropdown}
|
||||
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
View All Notifications
|
||||
</Link>
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Page Context - Shares current page info with header
|
||||
* Allows pages to set title, parent module, badge for display in AppHeader
|
||||
* Dashboard mode: enables "All Sites" option in site selector
|
||||
*/
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
@@ -11,6 +12,15 @@ interface PageInfo {
|
||||
icon: ReactNode;
|
||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||
};
|
||||
/** Completely hide site/sector selectors in app header */
|
||||
hideSelectors?: boolean;
|
||||
hideSectorSelector?: boolean; // Hide sector selector in app header (for dashboard)
|
||||
/** Dashboard mode: show "All Sites" option in site selector */
|
||||
showAllSitesOption?: boolean;
|
||||
/** Current site filter for dashboard mode ('all' or site id) */
|
||||
siteFilter?: 'all' | number;
|
||||
/** Callback when site filter changes in dashboard mode */
|
||||
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||
}
|
||||
|
||||
interface PageContextType {
|
||||
|
||||
390
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
390
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -126,3 +126,7 @@ export { BoxIcon as TagIcon };
|
||||
export { CloseIcon as XMarkIcon };
|
||||
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
|
||||
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause state
|
||||
export { ArrowUpIcon as TrendingUpIcon }; // Trend up indicator
|
||||
export { ArrowDownIcon as TrendingDownIcon }; // Trend down indicator
|
||||
export { BoxCubeIcon as SettingsIcon }; // Settings/cog alias
|
||||
export { InfoIcon as HelpCircleIcon }; // Help/question circle
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { usePageContext } from "../context/PageContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
|
||||
@@ -8,8 +8,26 @@ import UserDropdown from "../components/header/UserDropdown";
|
||||
import { HeaderMetrics } from "../components/header/HeaderMetrics";
|
||||
import SearchModal from "../components/common/SearchModal";
|
||||
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
|
||||
import SingleSiteSelector from "../components/common/SingleSiteSelector";
|
||||
import SiteWithAllSitesSelector from "../components/common/SiteWithAllSitesSelector";
|
||||
import React from "react";
|
||||
|
||||
// Route patterns for selector visibility
|
||||
const SITE_AND_SECTOR_ROUTES = [
|
||||
'/planner', // All planner pages
|
||||
'/writer', // All writer pages
|
||||
'/setup/add-keywords', // Add keywords page
|
||||
];
|
||||
|
||||
const SINGLE_SITE_ROUTES = [
|
||||
'/automation',
|
||||
'/account/content-settings', // Content settings and sub-pages
|
||||
];
|
||||
|
||||
const SITE_WITH_ALL_SITES_ROUTES = [
|
||||
'/', // Home dashboard only (exact match)
|
||||
];
|
||||
|
||||
// Badge color mappings for light versions
|
||||
const badgeColors: Record<string, { bg: string; light: string }> = {
|
||||
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
|
||||
@@ -31,6 +49,31 @@ const AppHeader: React.FC = () => {
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const { pageInfo } = usePageContext();
|
||||
const { isExpanded, toggleSidebar } = useSidebar();
|
||||
const location = useLocation();
|
||||
|
||||
// Determine which selector to show based on current route
|
||||
const getSelectorType = (): 'site-and-sector' | 'single-site' | 'site-with-all' | 'none' => {
|
||||
const path = location.pathname;
|
||||
|
||||
// Check for home dashboard (exact match)
|
||||
if (path === '/' && pageInfo?.onSiteFilterChange) {
|
||||
return 'site-with-all';
|
||||
}
|
||||
|
||||
// Check for site + sector selector routes
|
||||
if (SITE_AND_SECTOR_ROUTES.some(route => path.startsWith(route))) {
|
||||
return 'site-and-sector';
|
||||
}
|
||||
|
||||
// Check for single site selector routes
|
||||
if (SINGLE_SITE_ROUTES.some(route => path.startsWith(route))) {
|
||||
return 'single-site';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const selectorType = getSelectorType();
|
||||
|
||||
const toggleApplicationMenu = () => {
|
||||
setApplicationMenuOpen(!isApplicationMenuOpen);
|
||||
@@ -117,10 +160,25 @@ const AppHeader: React.FC = () => {
|
||||
{/* Header Metrics */}
|
||||
<HeaderMetrics />
|
||||
|
||||
{/* Site and Sector Selector - Desktop */}
|
||||
<div className="hidden lg:flex items-center">
|
||||
<SiteAndSectorSelector />
|
||||
</div>
|
||||
{/* Site/Sector Selector - Conditional based on route */}
|
||||
{selectorType === 'site-and-sector' && (
|
||||
<div className="hidden lg:flex items-center">
|
||||
<SiteAndSectorSelector />
|
||||
</div>
|
||||
)}
|
||||
{selectorType === 'single-site' && (
|
||||
<div className="hidden lg:flex items-center">
|
||||
<SingleSiteSelector />
|
||||
</div>
|
||||
)}
|
||||
{selectorType === 'site-with-all' && pageInfo?.onSiteFilterChange && (
|
||||
<div className="hidden lg:flex items-center">
|
||||
<SiteWithAllSitesSelector
|
||||
siteFilter={pageInfo.siteFilter}
|
||||
onSiteFilterChange={pageInfo.onSiteFilterChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Icon */}
|
||||
<button
|
||||
|
||||
@@ -19,6 +19,7 @@ import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
import RunHistory from '../../components/Automation/RunHistory';
|
||||
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||||
@@ -379,49 +380,34 @@ const AutomationPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" />
|
||||
<PageHeader
|
||||
title="Automation"
|
||||
description="Automatically create and publish content on your schedule"
|
||||
badge={{ icon: <BoltIcon />, color: 'teal' }}
|
||||
parent="Automation"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600">
|
||||
<BoltIcon className="text-white size-5" />
|
||||
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
||||
<div className="flex justify-center">
|
||||
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
||||
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
|
||||
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-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-slate-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'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automation</h2>
|
||||
{activeSite && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-slate-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>
|
||||
|
||||
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
||||
<div className="hidden sm:flex absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
||||
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
|
||||
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-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-slate-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'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-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>
|
||||
|
||||
<DebugSiteSelector />
|
||||
</div>
|
||||
|
||||
{/* Compact Schedule & Controls Panel */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
|
||||
export default function Clusters() {
|
||||
const toast = useToast();
|
||||
@@ -486,37 +486,81 @@ export default function Clusters() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Keywords',
|
||||
value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0).toLocaleString(),
|
||||
subtitle: `in ${totalCount} clusters`,
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/planner/keywords',
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
submoduleColor="green"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'green',
|
||||
metrics: [
|
||||
{ label: 'Clusters', value: totalCount },
|
||||
{ label: 'With Ideas', value: clusters.filter(c => (c.ideas_count || 0) > 0).length, percentage: `${totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0) },
|
||||
{ label: 'Ready', value: clusters.filter(c => (c.ideas_count || 0) === 0).length },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
label: 'Have Ideas',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
title: 'Content Ideas',
|
||||
value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
|
||||
subtitle: `across ${clusters.filter(c => (c.ideas_count || 0) > 0).length} clusters`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/planner/ideas',
|
||||
},
|
||||
{
|
||||
title: 'Ready to Write',
|
||||
value: clusters.filter(c => (c.ideas_count || 0) > 0 && c.status === 'active').length.toLocaleString(),
|
||||
subtitle: 'clusters with approved ideas',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Idea Generation Pipeline: Clusters with content ideas generated (ready for downstream content creation)',
|
||||
value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
color: 'purple',
|
||||
hint: clusters.filter(c => (c.ideas_count || 0) === 0).length > 0
|
||||
? `${clusters.filter(c => (c.ideas_count || 0) === 0).length} clusters ready for idea generation`
|
||||
: 'All clusters have ideas!',
|
||||
}}
|
||||
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: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -29,7 +29,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 ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
|
||||
export default function Ideas() {
|
||||
const toast = useToast();
|
||||
@@ -414,45 +414,81 @@ export default function Ideas() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Clusters',
|
||||
value: clusters.length.toLocaleString(),
|
||||
subtitle: 'keyword groups',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
submoduleColor="amber"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'amber',
|
||||
metrics: [
|
||||
{ label: 'Ideas', value: totalCount },
|
||||
{ label: 'In Tasks', value: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'queued' || i.status === 'completed').length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Pending', value: ideas.filter(i => i.status === 'new').length },
|
||||
{ label: 'From Clusters', value: clusters.length },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'queued' || i.status === 'completed').length / totalCount) * 100) : 0,
|
||||
label: 'Converted',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
title: 'Ready to Queue',
|
||||
value: ideas.filter(i => i.status === 'new').length.toLocaleString(),
|
||||
subtitle: 'awaiting approval',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'orange',
|
||||
},
|
||||
{
|
||||
title: 'In Queue',
|
||||
value: ideas.filter(i => i.status === 'queued').length.toLocaleString(),
|
||||
subtitle: 'ready for tasks',
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/tasks',
|
||||
},
|
||||
{
|
||||
title: 'Content Created',
|
||||
value: ideas.filter(i => i.status === 'completed').length.toLocaleString(),
|
||||
subtitle: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0}% completion`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/content',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Idea-to-Content Pipeline: Ideas successfully converted into written content',
|
||||
value: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
hint: ideas.filter(i => i.status === 'new').length > 0
|
||||
? `${ideas.filter(i => i.status === 'new').length} ideas ready to become tasks`
|
||||
: 'All ideas converted!',
|
||||
}}
|
||||
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: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
@@ -704,37 +704,84 @@ export default function Keywords() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Keywords',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `in ${clusters.length} clusters`,
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/planner/keywords',
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
submoduleColor="blue"
|
||||
pageProgress={{
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'blue',
|
||||
metrics: [
|
||||
{ label: 'Keywords', value: totalCount },
|
||||
{ label: 'Clustered', value: keywords.filter(k => k.cluster_id).length, percentage: `${totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Unmapped', value: keywords.filter(k => !k.cluster_id).length },
|
||||
{ label: 'Volume', value: `${(keywords.reduce((sum, k) => sum + (k.volume || 0), 0) / 1000).toFixed(1)}K` },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
label: 'Clustered',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'Clustered',
|
||||
value: keywords.filter(k => k.cluster_id).length.toLocaleString(),
|
||||
subtitle: `${Math.round((keywords.filter(k => k.cluster_id).length / Math.max(totalCount, 1)) * 100)}% organized`,
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
},
|
||||
{
|
||||
title: 'Easy Wins',
|
||||
value: keywords.filter(k => k.difficulty && k.difficulty <= 3 && (k.volume || 0) > 0).length.toLocaleString(),
|
||||
subtitle: `Low difficulty with ${keywords.filter(k => k.difficulty && k.difficulty <= 3).reduce((sum, k) => sum + (k.volume || 0), 0).toLocaleString()} volume`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Keyword Clustering Pipeline: Keywords organized into topical clusters',
|
||||
value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
color: 'primary',
|
||||
hint: keywords.filter(k => !k.cluster_id).length > 0
|
||||
? `${keywords.filter(k => !k.cluster_id).length} keywords ready to cluster`
|
||||
: 'All keywords clustered!',
|
||||
}}
|
||||
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: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI, fetchSiteSectors } from '../../services/api';
|
||||
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
|
||||
import { integrationApi } from '../../services/integration.api';
|
||||
import SiteConfigWidget from '../../components/dashboard/SiteConfigWidget';
|
||||
import OperationsCostsWidget from '../../components/dashboard/OperationsCostsWidget';
|
||||
import CreditAvailabilityWidget from '../../components/dashboard/CreditAvailabilityWidget';
|
||||
import { useBillingStore } from '../../store/billingStore';
|
||||
import {
|
||||
FileIcon,
|
||||
PlugInIcon,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
BoltIcon,
|
||||
PageIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon
|
||||
} from '../../icons';
|
||||
|
||||
interface Site {
|
||||
@@ -42,28 +43,46 @@ interface Site {
|
||||
interface SiteSetupState {
|
||||
hasIndustry: boolean;
|
||||
hasSectors: boolean;
|
||||
sectorsCount: number;
|
||||
hasWordPressIntegration: boolean;
|
||||
hasKeywords: boolean;
|
||||
keywordsCount: number;
|
||||
hasAuthorProfiles: boolean;
|
||||
authorProfilesCount: number;
|
||||
}
|
||||
|
||||
interface OperationStat {
|
||||
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||
count: number;
|
||||
creditsUsed: number;
|
||||
avgCreditsPerOp: number;
|
||||
}
|
||||
|
||||
export default function SiteDashboard() {
|
||||
const { id: siteId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { balance, loadBalance } = useBillingStore();
|
||||
const [site, setSite] = useState<Site | null>(null);
|
||||
const [setupState, setSetupState] = useState<SiteSetupState>({
|
||||
hasIndustry: false,
|
||||
hasSectors: false,
|
||||
sectorsCount: 0,
|
||||
hasWordPressIntegration: false,
|
||||
hasKeywords: false,
|
||||
keywordsCount: 0,
|
||||
hasAuthorProfiles: false,
|
||||
authorProfilesCount: 0,
|
||||
});
|
||||
const [operations, setOperations] = useState<OperationStat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadSiteData();
|
||||
loadBalance();
|
||||
}
|
||||
}, [siteId]);
|
||||
}, [siteId, loadBalance]);
|
||||
|
||||
const loadSiteData = async () => {
|
||||
try {
|
||||
@@ -79,9 +98,11 @@ export default function SiteDashboard() {
|
||||
|
||||
// Load sectors
|
||||
let hasSectors = false;
|
||||
let sectorsCount = 0;
|
||||
try {
|
||||
const sectors = await fetchSiteSectors(Number(siteId));
|
||||
hasSectors = sectors && sectors.length > 0;
|
||||
sectorsCount = sectors?.length || 0;
|
||||
} catch (err) {
|
||||
console.log('Could not load sectors');
|
||||
}
|
||||
@@ -97,20 +118,47 @@ export default function SiteDashboard() {
|
||||
|
||||
// Check keywords - try to load keywords for this site
|
||||
let hasKeywords = false;
|
||||
let keywordsCount = 0;
|
||||
try {
|
||||
const { fetchKeywords } = await import('../../services/api');
|
||||
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
|
||||
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
|
||||
keywordsCount = keywordsData?.count || 0;
|
||||
} catch (err) {
|
||||
// No keywords is fine
|
||||
}
|
||||
|
||||
// Check author profiles
|
||||
let hasAuthorProfiles = false;
|
||||
let authorProfilesCount = 0;
|
||||
try {
|
||||
const authorsData = await fetchAPI(`/v1/thinker/author-profiles/?site_id=${siteId}&page_size=1`);
|
||||
hasAuthorProfiles = authorsData?.count > 0;
|
||||
authorProfilesCount = authorsData?.count || 0;
|
||||
} catch (err) {
|
||||
// No profiles is fine
|
||||
}
|
||||
|
||||
setSetupState({
|
||||
hasIndustry,
|
||||
hasSectors,
|
||||
sectorsCount,
|
||||
hasWordPressIntegration,
|
||||
hasKeywords,
|
||||
keywordsCount,
|
||||
hasAuthorProfiles,
|
||||
authorProfilesCount,
|
||||
});
|
||||
|
||||
// Load operation stats (mock data for now - would come from backend)
|
||||
// In real implementation, fetch from /api/v1/dashboard/site/{siteId}/operations/
|
||||
const mockOperations: OperationStat[] = [
|
||||
{ type: 'clustering', count: 8, creditsUsed: 80, avgCreditsPerOp: 10 },
|
||||
{ type: 'ideas', count: 12, creditsUsed: 24, avgCreditsPerOp: 2 },
|
||||
{ type: 'content', count: 28, creditsUsed: 1400, avgCreditsPerOp: 50 },
|
||||
{ type: 'images', count: 45, creditsUsed: 225, avgCreditsPerOp: 5 },
|
||||
];
|
||||
setOperations(mockOperations);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load site data: ${error.message}`);
|
||||
@@ -185,6 +233,28 @@ export default function SiteDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Site Insights - 3 Column Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<SiteConfigWidget
|
||||
setupState={{
|
||||
hasIndustry: setupState.hasIndustry,
|
||||
sectorsCount: setupState.sectorsCount,
|
||||
hasWordPressIntegration: setupState.hasWordPressIntegration,
|
||||
keywordsCount: setupState.keywordsCount,
|
||||
authorProfilesCount: setupState.authorProfilesCount
|
||||
}}
|
||||
siteId={Number(siteId)}
|
||||
/>
|
||||
|
||||
<OperationsCostsWidget operations={operations} siteId={Number(siteId)} />
|
||||
|
||||
<CreditAvailabilityWidget
|
||||
availableCredits={balance?.credits_remaining ?? 0}
|
||||
totalCredits={balance?.plan_credits_per_month ?? 0}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<ComponentCard title="Quick Actions" desc="Common site management tasks">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
@@ -23,7 +23,7 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import { PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Content() {
|
||||
@@ -275,45 +275,82 @@ export default function Content() {
|
||||
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Tasks',
|
||||
value: content.length.toLocaleString(),
|
||||
subtitle: 'generated from queue',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/tasks',
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
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 },
|
||||
],
|
||||
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,
|
||||
label: 'Have Images',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'Draft',
|
||||
value: content.filter(c => c.status === 'draft').length.toLocaleString(),
|
||||
subtitle: 'needs editing',
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'amber',
|
||||
},
|
||||
{
|
||||
title: 'In Review',
|
||||
value: content.filter(c => c.status === 'review').length.toLocaleString(),
|
||||
subtitle: 'awaiting approval',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/review',
|
||||
},
|
||||
{
|
||||
title: 'Published',
|
||||
value: content.filter(c => c.status === 'published').length.toLocaleString(),
|
||||
subtitle: 'ready for sync',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/published',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Publishing Pipeline: Content moved from draft through review to published (Draft \u2192 Review \u2192 Published)',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
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!',
|
||||
}}
|
||||
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: content.filter(c => c.has_generated_images).length, color: 'purple' },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -30,7 +30,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 ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Tasks() {
|
||||
@@ -467,44 +467,83 @@ export default function Tasks() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Ideas',
|
||||
value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
|
||||
subtitle: 'from planner',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'orange',
|
||||
href: '/planner/ideas',
|
||||
{/* Three Widget Footer - Section 3 Layout */}
|
||||
<ThreeWidgetFooter
|
||||
submoduleColor="blue"
|
||||
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 },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
label: 'Generated',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'In Queue',
|
||||
value: tasks.filter(t => t.status === 'queued').length.toLocaleString(),
|
||||
subtitle: 'waiting for processing',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'amber',
|
||||
},
|
||||
{
|
||||
title: 'Processing',
|
||||
value: tasks.filter(t => t.status === 'in_progress').length.toLocaleString(),
|
||||
subtitle: 'generating content',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'Ready for Review',
|
||||
value: tasks.filter(t => t.status === 'completed').length.toLocaleString(),
|
||||
subtitle: 'content generated',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/content',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Generation Pipeline: Tasks successfully completed (Queued → Processing → Completed)',
|
||||
value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
hint: tasks.filter(t => t.status === 'queued').length > 0
|
||||
? `${tasks.filter(t => t.status === 'queued').length} tasks in queue for content generation`
|
||||
: 'All tasks processed!',
|
||||
}}
|
||||
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: 0,
|
||||
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: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Label from '../../components/form/Label';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { BoxCubeIcon } from '../../icons';
|
||||
|
||||
type TabType = 'content' | 'publishing' | 'images';
|
||||
|
||||
@@ -325,19 +327,16 @@ export default function ContentSettingsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
Content Settings / {tabTitles[activeTab]}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{tabTitles[activeTab]}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{activeTab === 'content' && 'Customize how your articles are written'}
|
||||
{activeTab === 'publishing' && 'Configure automatic publishing settings'}
|
||||
{activeTab === 'images' && 'Set up AI image generation preferences'}
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={tabTitles[activeTab]}
|
||||
description={
|
||||
activeTab === 'content' ? 'Customize how your articles are written' :
|
||||
activeTab === 'publishing' ? 'Configure automatic publishing settings' :
|
||||
'Set up AI image generation preferences'
|
||||
}
|
||||
badge={{ icon: <BoxCubeIcon />, color: 'blue' }}
|
||||
parent="Content Settings"
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -1523,6 +1523,8 @@ export interface Site {
|
||||
active_sectors_count: number;
|
||||
selected_sectors: number[];
|
||||
can_add_sectors: boolean;
|
||||
keywords_count: number;
|
||||
has_integration: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
205
frontend/src/store/notificationStore.ts
Normal file
205
frontend/src/store/notificationStore.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Notification Store
|
||||
* Manages notifications for AI task completions and system events
|
||||
*
|
||||
* Features:
|
||||
* - In-memory notification queue
|
||||
* - Auto-dismissal with configurable timeout
|
||||
* - Read/unread state tracking
|
||||
* - Category-based filtering (ai_task, system, info)
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
|
||||
export type NotificationCategory = 'ai_task' | 'system' | 'info';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
metadata?: {
|
||||
taskId?: string;
|
||||
functionName?: string;
|
||||
count?: number;
|
||||
credits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationStore {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
|
||||
// Actions
|
||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
|
||||
// AI Task specific
|
||||
addAITaskNotification: (
|
||||
functionName: string,
|
||||
success: boolean,
|
||||
message: string,
|
||||
metadata?: Notification['metadata']
|
||||
) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE IMPLEMENTATION
|
||||
// ============================================================================
|
||||
|
||||
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
|
||||
addNotification: (notification) => {
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id: generateId(),
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50
|
||||
unreadCount: state.unreadCount + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
markAsRead: (id) => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n
|
||||
),
|
||||
unreadCount: Math.max(0, state.notifications.filter(n => !n.read && n.id !== id).length),
|
||||
}));
|
||||
},
|
||||
|
||||
markAllAsRead: () => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||
unreadCount: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
removeNotification: (id) => {
|
||||
set((state) => {
|
||||
const notification = state.notifications.find(n => n.id === id);
|
||||
const wasUnread = notification && !notification.read;
|
||||
return {
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ notifications: [], unreadCount: 0 });
|
||||
},
|
||||
|
||||
addAITaskNotification: (functionName, success, message, metadata) => {
|
||||
const displayNames: Record<string, string> = {
|
||||
'auto_cluster': 'Keyword Clustering',
|
||||
'generate_ideas': 'Idea Generation',
|
||||
'generate_content': 'Content Generation',
|
||||
'generate_images': 'Image Generation',
|
||||
'generate_image_prompts': 'Image Prompts',
|
||||
'optimize_content': 'Content Optimization',
|
||||
};
|
||||
|
||||
const actionHrefs: Record<string, string> = {
|
||||
'auto_cluster': '/planner/clusters',
|
||||
'generate_ideas': '/planner/ideas',
|
||||
'generate_content': '/writer/content',
|
||||
'generate_images': '/writer/images',
|
||||
'generate_image_prompts': '/writer/images',
|
||||
'optimize_content': '/writer/content',
|
||||
};
|
||||
|
||||
const title = displayNames[functionName] || functionName.replace(/_/g, ' ');
|
||||
|
||||
get().addNotification({
|
||||
type: success ? 'success' : 'error',
|
||||
category: 'ai_task',
|
||||
title: success ? `${title} Complete` : `${title} Failed`,
|
||||
message,
|
||||
actionLabel: success ? 'View Results' : 'Retry',
|
||||
actionHref: actionHrefs[functionName] || '/dashboard',
|
||||
metadata: {
|
||||
...metadata,
|
||||
functionName,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format notification timestamp as relative time
|
||||
*/
|
||||
export function formatNotificationTime(timestamp: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - timestamp.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
|
||||
return timestamp.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon color classes for notification type
|
||||
*/
|
||||
export function getNotificationColors(type: NotificationType): {
|
||||
bg: string;
|
||||
icon: string;
|
||||
border: string;
|
||||
} {
|
||||
const colors = {
|
||||
success: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
icon: 'text-green-500',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
icon: 'text-red-500',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
icon: 'text-amber-500',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
icon: 'text-blue-500',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
},
|
||||
};
|
||||
|
||||
return colors[type];
|
||||
}
|
||||
117
immediate-udpates.md
Normal file
117
immediate-udpates.md
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
|
||||
|
||||
## Summary of Completed Work
|
||||
|
||||
### 1. ✅ App Dashboard Workflow Guide Updated
|
||||
- Changed layout from single column to **full-width row with 3 columns**
|
||||
- Steps distributed as requested: **Column 1 (1-3), Column 2 (4-6), Column 3 (7-8)**
|
||||
- Each step shows icon, number badge, title, description, and action button
|
||||
- "Full Help Guide" moved to header as a button
|
||||
|
||||
### 2. ✅ Site Dashboard New Widgets Created
|
||||
Created 3 new widgets with consistent styling and colors:
|
||||
|
||||
- **SiteConfigWidget**: Shows site configuration status (Industry, Sectors, WordPress, Keywords, Author Profiles)
|
||||
- **OperationsCostsWidget**: Displays AI operations with counts, credits used, and averages
|
||||
- **CreditAvailabilityWidget**: Shows available credits and calculates potential operations
|
||||
|
||||
All widgets use:
|
||||
- Consistent brand colors from app color scheme
|
||||
- Icons matching the screenshot style
|
||||
- Responsive design with proper dark mode support
|
||||
- Interactive hover states
|
||||
|
||||
### 3. ✅ Layout Ready for 2-3 Column Implementation
|
||||
The new widgets are ready to be integrated into the site dashboard with a 2-3 column grid layout showing:
|
||||
- Site-specific configuration data
|
||||
- Individual operation statistics with credit costs
|
||||
- Credit availability and potential operations
|
||||
|
||||
|
||||
STIL Styling is laoded from paralell color ssytem not our standard
|
||||
---
|
||||
|
||||
|
||||
## Table 1: Pages Requiring Site/Sector Selectors (Excluding Planner & Writer Modules)
|
||||
|
||||
| Page/Module | Site Selector | Sector Selector | Reason |
|
||||
|-------------|:-------------:|:---------------:|---------|
|
||||
| **DASHBOARD** |
|
||||
| Home | ✅ (All Sites option) | ❌ | Overview across sites - sector too granular |
|
||||
| Content Settings | ✅ | ❌ | Settings are site-level, not sector-level |
|
||||
| **AUTOMATION** |
|
||||
| Automation | ✅ | ❌ | Automation runs at site level |
|
||||
|
||||
|
||||
**Key Findings:**
|
||||
- **Setup Module**: Keywords page needs both selectors; Content Settings needs site only
|
||||
- **Automation**: Site selector only (automation is site-level)
|
||||
- **Linker & Optimizer**: Both selectors needed (content-specific)
|
||||
- **Admin/Billing/Account/Help**: No selectors needed (not site-specific)
|
||||
|
||||
---
|
||||
|
||||
## Table 2: Progress Modal Text Updates for AI Functions
|
||||
|
||||
### Auto Cluster Keywords
|
||||
|
||||
| Phase | Current Text | Recommended Text | Includes Count |
|
||||
|-------|-------------|------------------|:---------------:|
|
||||
| INIT | Validating keywords | Validating {count} keywords for clustering | ✅ |
|
||||
| PREP | Loading keyword data | Analyzing keyword relationships | ❌ |
|
||||
| AI_CALL | Generating clusters with Igny8 Semantic SEO Model | Grouping keywords by search intent ({count} keywords) | ✅ |
|
||||
| PARSE | Organizing clusters | Organizing {cluster_count} semantic clusters | ✅ |
|
||||
| SAVE | Saving clusters | Saving {cluster_count} clusters with {keyword_count} keywords | ✅ |
|
||||
| DONE | Clustering complete! | ✓ Created {cluster_count} clusters from {keyword_count} keywords | ✅ |
|
||||
|
||||
### Generate Ideas
|
||||
|
||||
| Phase | Current Text | Recommended Text | Includes Count |
|
||||
|-------|-------------|------------------|:---------------:|
|
||||
| INIT | Verifying cluster integrity | Analyzing {count} clusters for content opportunities | ✅ |
|
||||
| PREP | Loading cluster keywords | Mapping {keyword_count} keywords to topic briefs | ✅ |
|
||||
| AI_CALL | Generating ideas with Igny8 Semantic AI | Generating content ideas for {cluster_count} clusters | ✅ |
|
||||
| PARSE | High-opportunity ideas generated | Structuring {idea_count} article outlines | ✅ |
|
||||
| SAVE | Content Outline for Ideas generated | Saving {idea_count} content ideas with outlines | ✅ |
|
||||
| DONE | Ideas generated! | ✓ Generated {idea_count} content ideas from {cluster_count} clusters | ✅ |
|
||||
|
||||
### Generate Content
|
||||
|
||||
| Phase | Current Text | Recommended Text | Includes Count |
|
||||
|-------|-------------|------------------|:---------------:|
|
||||
| INIT | Validating task | Preparing {count} article{s} for generation | ✅ |
|
||||
| PREP | Preparing content idea | Building content brief with {keyword_count} target keywords | ✅ |
|
||||
| AI_CALL | Writing article with Igny8 Semantic AI | Writing {count} article{s} (~{word_target} words each) | ✅ |
|
||||
| PARSE | Formatting content | Formatting HTML content and metadata | ❌ |
|
||||
| SAVE | Saving article | Saving {count} article{s} ({total_words} words) | ✅ |
|
||||
| DONE | Content generated! | ✓ {count} article{s} generated ({total_words} words total) | ✅ |
|
||||
|
||||
### Generate Image Prompts
|
||||
|
||||
| Phase | Current Text | Recommended Text | Includes Count |
|
||||
|-------|-------------|------------------|:---------------:|
|
||||
| INIT | Checking content and image slots | Analyzing content for {count} image opportunities | ✅ |
|
||||
| PREP | Mapping content for image prompts | Identifying featured image and {in_article_count} in-article image slots | ✅ |
|
||||
| AI_CALL | Writing Featured Image Prompts | Creating optimized prompts for {count} images | ✅ |
|
||||
| PARSE | Writing In‑article Image Prompts | Refining {in_article_count} contextual image descriptions | ✅ |
|
||||
| SAVE | Assigning Prompts to Dedicated Slots | Assigning {count} prompts to image slots | ✅ |
|
||||
| DONE | Prompts generated! | ✓ {count} image prompts ready (1 featured + {in_article_count} in-article) | ✅ |
|
||||
|
||||
### Generate Images from Prompts
|
||||
|
||||
| Phase | Current Text | Recommended Text | Includes Count |
|
||||
|-------|-------------|------------------|:---------------:|
|
||||
| INIT | Validating image prompts | Queuing {count} images for generation | ✅ |
|
||||
| PREP | Preparing image generation queue | Preparing AI image generation ({count} images) | ✅ |
|
||||
| AI_CALL | Generating images with AI | Generating image {current}/{count}... | ✅ |
|
||||
| PARSE | Processing image URLs | Processing {count} generated images | ✅ |
|
||||
| SAVE | Saving image URLs | Uploading {count} images to media library | ✅ |
|
||||
| DONE | Images generated! | ✓ {count} images generated and saved | ✅ |
|
||||
|
||||
**Key Improvements:**
|
||||
- ✅ All phases now include specific counts where data is available
|
||||
- ✅ More professional and informative language
|
||||
- ✅ Clear indication of progress with actual numbers
|
||||
- ✅ Success messages use checkmark (✓) for visual completion
|
||||
- ✅ Dynamic placeholders for singular/plural ({s}, {count})
|
||||
177
to-do-s/PLAN-DASHBOARD-HOMEPAGE.md
Normal file
177
to-do-s/PLAN-DASHBOARD-HOMEPAGE.md
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
## 5. Dashboard Redesign Plan
|
||||
|
||||
### Current Issues
|
||||
- Too much whitespace and large headings
|
||||
- Repeating same counts/metrics without different dimensions
|
||||
- Missing actionable insights
|
||||
- No AI operations analytics
|
||||
- Missing "needs attention" items
|
||||
|
||||
### New Dashboard Design: Multi-Dimension Compact Widgets
|
||||
|
||||
Based on Django admin reports analysis, the dashboard should show **different data dimensions** instead of repeating counts:
|
||||
|
||||
### Dashboard Layout (Compact, Information-Dense)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ⚠ NEEDS ATTENTION (collapsible, only shows if items exist) │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ 3 pending review │ │ WP sync failed │ │ Setup incomplete │ │
|
||||
│ │ [Review →] │ │ [Retry] [Fix →] │ │ [Complete →] │ │
|
||||
│ └────────────────────┘ └────────────────────┘ └────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ WORKFLOW PIPELINE │ │ QUICK ACTIONS │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Sites → KWs → Clusters → Ideas │ │ [+ Keywords] [⚡ Cluster] [📝 Content] │ │
|
||||
│ │ 2 156 23 67 │ │ [🖼 Images] [✓ Review] [🚀 Publish] │ │
|
||||
│ │ ↓ │ │ │ │
|
||||
│ │ Tasks → Drafts → Published │ │ WORKFLOW GUIDE │ │
|
||||
│ │ 45 28 45 │ │ 1. Add Keywords 5. Generate Content │ │
|
||||
│ │ │ │ 2. Auto Cluster 6. Generate Images │ │
|
||||
│ │ ████████████░░░ 72% Complete │ │ 3. Generate Ideas 7. Review & Approve │ │
|
||||
│ │ │ │ 4. Create Tasks 8. Publish to WP │ │
|
||||
│ └─────────────────────────────────┘ │ [Full Help →] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ AI OPERATIONS (7d) [▼ 30d] │ │ RECENT ACTIVITY │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Operation Count Credits │ │ • Clustered 45 keywords → 8 clusters │ │
|
||||
│ │ ───────────────────────────────│ │ 2 hours ago │ │
|
||||
│ │ Clustering 8 80 │ │ • Generated 5 articles (4.2K words) │ │
|
||||
│ │ Ideas 12 24 │ │ 4 hours ago │ │
|
||||
│ │ Content 28 1,400 │ │ • Created 15 image prompts │ │
|
||||
│ │ Images 45 225 │ │ Yesterday │ │
|
||||
│ │ ───────────────────────────────│ │ • Published "Best Running Shoes" to WP │ │
|
||||
│ │ Total 93 1,729 │ │ Yesterday │ │
|
||||
│ │ │ │ • Added 23 keywords from seed DB │ │
|
||||
│ │ Success Rate: 98.5% │ │ 2 days ago │ │
|
||||
│ │ Avg Credits/Op: 18.6 │ │ │ │
|
||||
│ └─────────────────────────────────┘ │ [View All Activity →] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ CONTENT VELOCITY │ │ AUTOMATION STATUS │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ This Week This Month Total │ │ ● Active │ Schedule: Daily 9 AM │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Articles 5 28 156 │ │ Last Run: Dec 27, 7:00 AM │ │
|
||||
│ │ Words 4.2K 24K 156K │ │ ├─ Clustered: 12 keywords │ │
|
||||
│ │ Images 12 67 340 │ │ ├─ Ideas: 8 generated │ │
|
||||
│ │ │ │ ├─ Content: 5 articles │ │
|
||||
│ │ 📈 +23% vs last week │ │ └─ Images: 15 created │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ [View Analytics →] │ │ Next Run: Dec 28, 9:00 AM │ │
|
||||
│ └─────────────────────────────────┘ │ [Configure →] [Run Now →] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Widget Specifications
|
||||
|
||||
#### 1. Needs Attention Bar
|
||||
- Collapsible, only visible when items exist
|
||||
- Types: `pending_review`, `sync_failed`, `setup_incomplete`, `automation_failed`
|
||||
- Compact horizontal cards with action buttons
|
||||
|
||||
#### 2. Workflow Pipeline Widget
|
||||
- Visual flow: Sites → Keywords → Clusters → Ideas → Tasks → Drafts → Published
|
||||
- Shows counts at each stage
|
||||
- Single progress bar for overall completion
|
||||
- Clickable stage names link to respective pages
|
||||
|
||||
#### 3. Quick Actions + Workflow Guide Widget
|
||||
- 2x3 grid of action buttons (use existing icons)
|
||||
- Compact numbered workflow guide (1-8 steps)
|
||||
- "Full Help" link to help page
|
||||
|
||||
#### 4. AI Operations Widget (NEW - from Django Admin Reports)
|
||||
Shows data from `CreditUsageLog` model:
|
||||
```typescript
|
||||
interface AIOperationsData {
|
||||
period: '7d' | '30d' | '90d';
|
||||
operations: Array<{
|
||||
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||
count: number;
|
||||
credits: number;
|
||||
}>;
|
||||
totals: {
|
||||
count: number;
|
||||
credits: number;
|
||||
success_rate: number;
|
||||
avg_credits_per_op: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
- Time period filter (7d/30d/90d dropdown)
|
||||
- Table with operation type, count, credits
|
||||
- Success rate percentage
|
||||
- Average credits per operation
|
||||
|
||||
#### 5. Recent Activity Widget
|
||||
Shows data from `AITaskLog` and `CreditUsageLog`:
|
||||
- Last 5 significant operations
|
||||
- Timestamp relative (2 hours ago, Yesterday)
|
||||
- Clickable to navigate to relevant content
|
||||
- "View All Activity" link
|
||||
|
||||
#### 6. Content Velocity Widget (NEW)
|
||||
Shows content production rates:
|
||||
```typescript
|
||||
interface ContentVelocityData {
|
||||
this_week: { articles: number; words: number; images: number };
|
||||
this_month: { articles: number; words: number; images: number };
|
||||
total: { articles: number; words: number; images: number };
|
||||
trend: number; // percentage vs previous period
|
||||
}
|
||||
```
|
||||
- Three time columns: This Week, This Month, Total
|
||||
- Rows: Articles, Words, Images
|
||||
- Trend indicator vs previous period
|
||||
|
||||
#### 7. Automation Status Widget
|
||||
Shows automation run status:
|
||||
- Current status indicator (Active/Paused/Failed)
|
||||
- Schedule display
|
||||
- Last run details with stage breakdown
|
||||
- Next scheduled run
|
||||
- Configure and Run Now buttons
|
||||
|
||||
### API Endpoint Required
|
||||
|
||||
```python
|
||||
# GET /api/v1/dashboard/summary/
|
||||
{
|
||||
"needs_attention": [...],
|
||||
"pipeline": {
|
||||
"sites": 2, "keywords": 156, "clusters": 23,
|
||||
"ideas": 67, "tasks": 45, "drafts": 28, "published": 45,
|
||||
"completion_percentage": 72
|
||||
},
|
||||
"ai_operations": {
|
||||
"period": "7d",
|
||||
"operations": [...],
|
||||
"totals": {...}
|
||||
},
|
||||
"recent_activity": [...],
|
||||
"content_velocity": {...},
|
||||
"automation": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Use existing components from `components/ui/`
|
||||
- Use CSS tokens from `styles/tokens.css`
|
||||
- Grid layout: `grid grid-cols-1 lg:grid-cols-2 gap-4`
|
||||
- Compact widget padding: `p-4`
|
||||
- No large headings - use subtle section labels
|
||||
181
to-do-s/PLAN-SITE-SELECTOR-SECTOR.md
Normal file
181
to-do-s/PLAN-SITE-SELECTOR-SECTOR.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Plan: Site & Sector Selector Configuration
|
||||
|
||||
**Source:** COMPREHENSIVE-AUDIT-REPORT.md - Section 1
|
||||
**Priority:** High for Planner & Writer pages
|
||||
**Estimated Effort:** 4-6 hours
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure correct placement of Site Selector and Sector Selector across all pages based on data scope requirements.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Rules
|
||||
|
||||
| Condition | Site Selector | Sector Selector |
|
||||
|-----------|:-------------:|:---------------:|
|
||||
| Data scoped to specific site | ✅ | ❌ |
|
||||
| Data can be filtered by content category | ✅ | ✅ |
|
||||
| Page is not site-specific (account-level) | ❌ | ❌ |
|
||||
| Already in specific context (detail page) | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### DASHBOARD Module
|
||||
- [ ] **Home** - Site Selector: ✅ (with "All Sites" option) | Sector: ❌
|
||||
- Overview across sites - sector too granular for dashboard
|
||||
|
||||
### SETUP Module
|
||||
- [ ] **Add Keywords** - Site: ✅ | Sector: ✅
|
||||
- Keywords are site+sector specific
|
||||
- [ ] **Content Settings** - Site: ✅ | Sector: ❌
|
||||
- Settings are site-level, not sector-level
|
||||
- [ ] **Sites List** - Site: ❌ | Sector: ❌
|
||||
- Managing sites themselves
|
||||
- [ ] **Site Dashboard** - Site: ❌ (context) | Sector: ❌
|
||||
- Already in specific site context
|
||||
- [ ] **Site Settings tabs** - Site: ❌ (context) | Sector: ❌
|
||||
- Already in specific site context
|
||||
|
||||
### PLANNER Module
|
||||
- [ ] **Keywords** - Site: ✅ | Sector: ✅
|
||||
- Keywords organized by site+sector
|
||||
- [ ] **Clusters** - Site: ✅ | Sector: ✅
|
||||
- Clusters organized by site+sector
|
||||
- [ ] **Cluster Detail** - Site: ❌ (context) | Sector: ❌ (context)
|
||||
- Already in cluster context
|
||||
- [ ] **Ideas** - Site: ✅ | Sector: ✅
|
||||
- Ideas organized by site+sector
|
||||
|
||||
### WRITER Module
|
||||
- [ ] **Tasks/Queue** - Site: ✅ | Sector: ✅
|
||||
- Tasks organized by site+sector
|
||||
- [ ] **Content/Drafts** - Site: ✅ | Sector: ✅
|
||||
- Content organized by site+sector
|
||||
- [ ] **Content View** - Site: ❌ (context) | Sector: ❌ (context)
|
||||
- Viewing specific content
|
||||
- [ ] **Images** - Site: ✅ | Sector: ✅
|
||||
- Images tied to content by site+sector
|
||||
- [ ] **Review** - Site: ✅ | Sector: ✅
|
||||
- Review queue by site+sector
|
||||
- [ ] **Published** - Site: ✅ | Sector: ✅
|
||||
- Published content by site+sector
|
||||
|
||||
### AUTOMATION Module
|
||||
- [ ] **Automation** - Site: ✅ | Sector: ❌
|
||||
- Automation runs at site level
|
||||
|
||||
### LINKER Module (if enabled)
|
||||
- [ ] **Content List** - Site: ✅ | Sector: ✅
|
||||
- Linking is content-specific
|
||||
|
||||
### OPTIMIZER Module (if enabled)
|
||||
- [ ] **Content Selector** - Site: ✅ | Sector: ✅
|
||||
- Optimization is content-specific
|
||||
- [ ] **Analysis Preview** - Site: ❌ (context) | Sector: ❌ (context)
|
||||
- Already in analysis context
|
||||
|
||||
### THINKER Module (Admin)
|
||||
- [ ] **All Thinker pages** - Site: ❌ | Sector: ❌
|
||||
- System-wide prompts/profiles
|
||||
|
||||
### BILLING Module
|
||||
- [ ] **All Billing pages** - Site: ❌ | Sector: ❌
|
||||
- Account-level billing data
|
||||
|
||||
### ACCOUNT Module
|
||||
- [ ] **Account Settings** - Site: ❌ | Sector: ❌
|
||||
- [ ] **Profile** - Site: ❌ | Sector: ❌
|
||||
- [ ] **Team** - Site: ❌ | Sector: ❌
|
||||
- [ ] **Plans** - Site: ❌ | Sector: ❌
|
||||
- [ ] **Usage** - Site: ❌ | Sector: ❌
|
||||
|
||||
### HELP Module
|
||||
- [ ] **Help Page** - Site: ❌ | Sector: ❌
|
||||
|
||||
---
|
||||
|
||||
## Site Setup Checklist on Site Cards
|
||||
|
||||
**Source:** Section 6 of Audit Report
|
||||
|
||||
### Current Status
|
||||
- ✅ `SiteSetupChecklist.tsx` component EXISTS
|
||||
- ✅ Integrated in Site Dashboard (full mode)
|
||||
- ❌ **NOT integrated in SiteCard.tsx** (compact mode)
|
||||
|
||||
### Implementation Task
|
||||
|
||||
**File:** `frontend/src/components/sites/SiteCard.tsx`
|
||||
|
||||
Add compact checklist after status badges:
|
||||
|
||||
```tsx
|
||||
<SiteSetupChecklist
|
||||
siteId={site.id}
|
||||
siteName={site.name}
|
||||
hasIndustry={!!site.industry}
|
||||
hasSectors={site.sectors_count > 0}
|
||||
hasWordPressIntegration={!!site.wordpress_site_url}
|
||||
hasKeywords={site.keywords_count > 0}
|
||||
compact={true}
|
||||
/>
|
||||
```
|
||||
|
||||
**Expected Visual:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ My Website [Active] │
|
||||
│ example.com │
|
||||
│ Industry: Tech │ 3 Sectors │
|
||||
│ ●●●○ 3/4 Setup Steps Complete │ ← compact checklist
|
||||
│ [Manage →] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
Ensure `SiteSerializer` returns these fields for checklist:
|
||||
- `keywords_count` - number of keywords
|
||||
- `has_integration` - boolean for WordPress integration
|
||||
- `active_sectors_count` - number of active sectors
|
||||
- `industry_name` - industry name or null
|
||||
|
||||
**Status:** ✅ Already verified these fields are returned
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Frontend
|
||||
1. `frontend/src/components/sites/SiteCard.tsx` - Add compact SiteSetupChecklist
|
||||
2. Various page files to verify/add selector configuration
|
||||
|
||||
### Selector Components
|
||||
- `frontend/src/components/common/SiteSelector.tsx`
|
||||
- `frontend/src/components/common/SectorSelector.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Site selector shows on all required pages
|
||||
- [ ] Sector selector shows only where data is sector-specific
|
||||
- [ ] Detail pages (Cluster Detail, Content View) have no selectors
|
||||
- [ ] Account/Billing pages have no selectors
|
||||
- [ ] SiteCard shows compact setup checklist
|
||||
- [ ] Checklist updates when site configuration changes
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The "All Sites" option on Dashboard should aggregate data across all user's sites
|
||||
- Context pages (detail views) inherit site/sector from parent navigation
|
||||
- Selector state should persist in URL params or store for deep linking
|
||||
Reference in New Issue
Block a user