final polish phase 1

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 21:27:37 +00:00
parent 627938aa95
commit 5f9a4b8dca
25 changed files with 3286 additions and 1397 deletions

View File

@@ -23,6 +23,8 @@ interface PageHeaderProps {
icon: ReactNode; icon: ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal'; 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; hideSiteSector?: boolean;
navigation?: ReactNode; // Kept for backwards compat but not rendered navigation?: ReactNode; // Kept for backwards compat but not rendered
workflowInsights?: any[]; // 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, onRefresh,
className = "", className = "",
badge, badge,
hideSelectors = false,
hideSiteSector = false, hideSiteSector = false,
actions, actions,
}: PageHeaderProps) { }: PageHeaderProps) {
@@ -54,11 +57,11 @@ export default function PageHeader({
const parentModule = parent || breadcrumb; const parentModule = parent || breadcrumb;
// Update page context with title and badge info for AppHeader // 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(() => { useEffect(() => {
setPageInfo({ title, parent: parentModule, badge }); setPageInfo({ title, parent: parentModule, badge, hideSelectors, hideSectorSelector: hideSiteSector });
return () => setPageInfo(null); return () => setPageInfo(null);
}, [pageInfoKey, badge?.color]); }, [pageInfoKey, badge?.color, hideSiteSector, hideSelectors]);
// Load sectors when active site changes // Load sectors when active site changes
useEffect(() => { useEffect(() => {

View 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>
);
}

View File

@@ -1,6 +1,9 @@
/** /**
* Combined Site and Sector Selector Component * Combined Site and Sector Selector Component
* Displays both site switcher and sector selector side by side with accent colors * 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 { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -15,10 +18,19 @@ import Button from '../ui/button/Button';
interface SiteAndSectorSelectorProps { interface SiteAndSectorSelectorProps {
hideSectorSelector?: boolean; 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({ export default function SiteAndSectorSelector({
hideSectorSelector = false, hideSectorSelector = false,
showAllSitesOption = false,
siteFilter,
onSiteFilterChange,
}: SiteAndSectorSelectorProps) { }: SiteAndSectorSelectorProps) {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); 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 { try {
await apiSetActiveSite(siteId); await apiSetActiveSite(siteId);
const selectedSite = sites.find(s => s.id === 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) => { const handleSectorSelect = (sectorId: number | null) => {
if (sectorId === null) { if (sectorId === null) {
setActiveSector(null); setActiveSector(null);
@@ -141,7 +186,7 @@ export default function SiteAndSectorSelector({
/> />
</svg> </svg>
<span className="max-w-[150px] truncate"> <span className="max-w-[150px] truncate">
{sitesLoading ? 'Loading...' : activeSite?.name || 'Select Site'} {getSiteDisplayText()}
</span> </span>
</span> </span>
<svg <svg
@@ -166,18 +211,44 @@ export default function SiteAndSectorSelector({
placement="bottom-left" placement="bottom-left"
className="w-64 p-2" 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) => ( {sites.map((site) => (
<DropdownItem <DropdownItem
key={site.id} key={site.id}
onItemClick={() => handleSiteSelect(site.id)} onItemClick={() => handleSiteSelect(site.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ 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" ? "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" : "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> <span className="flex-1">{site.name}</span>
{activeSite?.id === site.id && ( {isSiteSelected(site.id) && (
<svg <svg
className="w-4 h-4 text-brand-600 dark:text-brand-400" className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor" fill="currentColor"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 { useState, useRef } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Dropdown } from "../ui/dropdown/Dropdown"; import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem"; 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() { export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [notifying, setNotifying] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const navigate = useNavigate();
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
removeNotification
} = useNotificationStore();
function toggleDropdown() { function toggleDropdown() {
setIsOpen(!isOpen); setIsOpen(!isOpen);
@@ -18,22 +85,31 @@ export default function NotificationDropdown() {
const handleClick = () => { const handleClick = () => {
toggleDropdown(); toggleDropdown();
setNotifying(false);
}; };
const handleNotificationClick = (id: string, href?: string) => {
markAsRead(id);
closeDropdown();
if (href) {
navigate(href);
}
};
return ( return (
<div className="relative"> <div className="relative">
<button <button
ref={buttonRef} 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" 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} onClick={handleClick}
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
> >
<span {/* Notification badge */}
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${ {unreadCount > 0 && (
!notifying ? "hidden" : "flex" <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 className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span> </span>
</span> )}
<svg <svg
className="fill-current" className="fill-current"
width="20" width="20"
@@ -49,335 +125,143 @@ export default function NotificationDropdown() {
/> />
</svg> </svg>
</button> </button>
<Dropdown <Dropdown
isOpen={isOpen} isOpen={isOpen}
onClose={closeDropdown} onClose={closeDropdown}
anchorRef={buttonRef} anchorRef={buttonRef as React.RefObject<HTMLElement>}
placement="bottom-right" 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]" 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"> <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"> <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> </h5>
<button <div className="flex items-center gap-2">
onClick={toggleDropdown} {unreadCount > 0 && (
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" <button
> onClick={markAllAsRead}
<svg className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
className="fill-current" >
width="24" Mark all read
height="24" </button>
viewBox="0 0 24 24" )}
xmlns="http://www.w3.org/2000/svg" <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 <svg
fillRule="evenodd" className="fill-current"
clipRule="evenodd" width="20"
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" height="20"
fill="currentColor" viewBox="0 0 24 24"
/> xmlns="http://www.w3.org/2000/svg"
</svg> >
</button> <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> </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>
<span className="block"> {/* Notification List */}
<span className="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400 space-x-1"> <ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
<span className="font-medium text-gray-800 dark:text-white/90"> {notifications.length === 0 ? (
Terry Franci <li className="flex flex-col items-center justify-center py-12 text-center">
</span> <div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<span> requests permission to change</span> <BoltIcon className="w-6 h-6 text-gray-400" />
<span className="font-medium text-gray-800 dark:text-white/90"> </div>
Project - Nganter App <p className="text-sm text-gray-500 dark:text-gray-400">
</span> No notifications yet
</span> </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
);
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400"> return (
<span>Project</span> <li key={notification.id}>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span> <DropdownItem
<span>5 min ago</span> onItemClick={() => handleNotificationClick(
</span> notification.id,
</span> notification.actionHref
</DropdownItem> )}
</li> 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>
<li> {/* Content */}
<DropdownItem <span className="flex-1 min-w-0">
onItemClick={closeDropdown} <span className="flex items-start justify-between gap-2">
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={`text-sm font-medium ${
> !notification.read
<span className="relative block w-full h-10 rounded-full z-1 max-w-10"> ? 'text-gray-900 dark:text-white'
<img : 'text-gray-700 dark:text-gray-300'
width={40} }`}>
height={40} {notification.title}
src="/images/user/user-03.jpg" </span>
alt="User" {!notification.read && (
className="w-full overflow-hidden rounded-full" <span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
/> )}
<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>
<span className="block"> <span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400"> {notification.message}
<span className="font-medium text-gray-800 dark:text-white/90"> </span>
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 className="flex items-center justify-between mt-2">
<span>Project</span> <span className="text-xs text-gray-500 dark:text-gray-400">
<span className="w-1 h-1 bg-gray-400 rounded-full"></span> {formatNotificationTime(notification.timestamp)}
<span>8 min ago</span> </span>
</span> {notification.actionLabel && notification.actionHref && (
</span> <span className="text-xs font-medium text-brand-600 dark:text-brand-400">
</DropdownItem> {notification.actionLabel}
</li> </span>
)}
<li> </span>
<DropdownItem </span>
onItemClick={closeDropdown} </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" </li>
> );
<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 */}
</ul> </ul>
<Link
to="/" {/* Footer */}
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" {notifications.length > 0 && (
> <Link
View All Notifications to="/notifications"
</Link> 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> </Dropdown>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
/** /**
* Page Context - Shares current page info with header * Page Context - Shares current page info with header
* Allows pages to set title, parent module, badge for display in AppHeader * 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'; import React, { createContext, useContext, useState, ReactNode } from 'react';
@@ -11,6 +12,15 @@ interface PageInfo {
icon: ReactNode; icon: ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal'; 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 { interface PageContextType {

View File

@@ -126,3 +126,7 @@ export { BoxIcon as TagIcon };
export { CloseIcon as XMarkIcon }; export { CloseIcon as XMarkIcon };
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state) export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause 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

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { usePageContext } from "../context/PageContext"; import { usePageContext } from "../context/PageContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { ThemeToggleButton } from "../components/common/ThemeToggleButton"; import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
@@ -8,8 +8,26 @@ import UserDropdown from "../components/header/UserDropdown";
import { HeaderMetrics } from "../components/header/HeaderMetrics"; import { HeaderMetrics } from "../components/header/HeaderMetrics";
import SearchModal from "../components/common/SearchModal"; import SearchModal from "../components/common/SearchModal";
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector"; import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
import SingleSiteSelector from "../components/common/SingleSiteSelector";
import SiteWithAllSitesSelector from "../components/common/SiteWithAllSitesSelector";
import React from "react"; 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 // Badge color mappings for light versions
const badgeColors: Record<string, { bg: string; light: string }> = { 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' }, 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 [isSearchOpen, setIsSearchOpen] = useState(false);
const { pageInfo } = usePageContext(); const { pageInfo } = usePageContext();
const { isExpanded, toggleSidebar } = useSidebar(); 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 = () => { const toggleApplicationMenu = () => {
setApplicationMenuOpen(!isApplicationMenuOpen); setApplicationMenuOpen(!isApplicationMenuOpen);
@@ -117,10 +160,25 @@ const AppHeader: React.FC = () => {
{/* Header Metrics */} {/* Header Metrics */}
<HeaderMetrics /> <HeaderMetrics />
{/* Site and Sector Selector - Desktop */} {/* Site/Sector Selector - Conditional based on route */}
<div className="hidden lg:flex items-center"> {selectorType === 'site-and-sector' && (
<SiteAndSectorSelector /> <div className="hidden lg:flex items-center">
</div> <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 */} {/* Search Icon */}
<button <button

View File

@@ -19,6 +19,7 @@ import ConfigModal from '../../components/Automation/ConfigModal';
import RunHistory from '../../components/Automation/RunHistory'; import RunHistory from '../../components/Automation/RunHistory';
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard'; import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ComponentCard from '../../components/common/ComponentCard'; import ComponentCard from '../../components/common/ComponentCard';
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard'; import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
import DebugSiteSelector from '../../components/common/DebugSiteSelector'; import DebugSiteSelector from '../../components/common/DebugSiteSelector';
@@ -379,49 +380,34 @@ const AutomationPage: React.FC = () => {
return ( return (
<> <>
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" /> <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"> <div className="space-y-6">
{/* Header */} {/* Compact Ready-to-Run card (header) - absolutely centered in header */}
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="flex justify-center">
<div className="flex-1"> <div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
<div className="flex items-center gap-3"> ${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="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600"> <div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
<BoltIcon className="text-white size-5" /> ${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>
<div> <div className="text-xs text-slate-600 dark:text-gray-400 truncate">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automation</h2> {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
{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> </div>
</div> </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> </div>
{/* Compact Schedule & Controls Panel */} {/* Compact Schedule & Controls Panel */}

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,15 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader'; 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 Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI, fetchSiteSectors } from '../../services/api'; import { fetchAPI, fetchSiteSectors } from '../../services/api';
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist'; import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
import { integrationApi } from '../../services/integration.api'; 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 { import {
FileIcon, FileIcon,
PlugInIcon, PlugInIcon,
@@ -21,7 +23,6 @@ import {
BoltIcon, BoltIcon,
PageIcon, PageIcon,
ArrowRightIcon, ArrowRightIcon,
ArrowUpIcon
} from '../../icons'; } from '../../icons';
interface Site { interface Site {
@@ -42,28 +43,46 @@ interface Site {
interface SiteSetupState { interface SiteSetupState {
hasIndustry: boolean; hasIndustry: boolean;
hasSectors: boolean; hasSectors: boolean;
sectorsCount: number;
hasWordPressIntegration: boolean; hasWordPressIntegration: boolean;
hasKeywords: 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() { export default function SiteDashboard() {
const { id: siteId } = useParams<{ id: string }>(); const { id: siteId } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const { balance, loadBalance } = useBillingStore();
const [site, setSite] = useState<Site | null>(null); const [site, setSite] = useState<Site | null>(null);
const [setupState, setSetupState] = useState<SiteSetupState>({ const [setupState, setSetupState] = useState<SiteSetupState>({
hasIndustry: false, hasIndustry: false,
hasSectors: false, hasSectors: false,
sectorsCount: 0,
hasWordPressIntegration: false, hasWordPressIntegration: false,
hasKeywords: false, hasKeywords: false,
keywordsCount: 0,
hasAuthorProfiles: false,
authorProfilesCount: 0,
}); });
const [operations, setOperations] = useState<OperationStat[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (siteId) { if (siteId) {
loadSiteData(); loadSiteData();
loadBalance();
} }
}, [siteId]); }, [siteId, loadBalance]);
const loadSiteData = async () => { const loadSiteData = async () => {
try { try {
@@ -79,9 +98,11 @@ export default function SiteDashboard() {
// Load sectors // Load sectors
let hasSectors = false; let hasSectors = false;
let sectorsCount = 0;
try { try {
const sectors = await fetchSiteSectors(Number(siteId)); const sectors = await fetchSiteSectors(Number(siteId));
hasSectors = sectors && sectors.length > 0; hasSectors = sectors && sectors.length > 0;
sectorsCount = sectors?.length || 0;
} catch (err) { } catch (err) {
console.log('Could not load sectors'); console.log('Could not load sectors');
} }
@@ -97,20 +118,47 @@ export default function SiteDashboard() {
// Check keywords - try to load keywords for this site // Check keywords - try to load keywords for this site
let hasKeywords = false; let hasKeywords = false;
let keywordsCount = 0;
try { try {
const { fetchKeywords } = await import('../../services/api'); const { fetchKeywords } = await import('../../services/api');
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 }); const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0; hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
keywordsCount = keywordsData?.count || 0;
} catch (err) { } catch (err) {
// No keywords is fine // 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({ setSetupState({
hasIndustry, hasIndustry,
hasSectors, hasSectors,
sectorsCount,
hasWordPressIntegration, hasWordPressIntegration,
hasKeywords, 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) { } catch (error: any) {
toast.error(`Failed to load site data: ${error.message}`); toast.error(`Failed to load site data: ${error.message}`);
@@ -185,6 +233,28 @@ export default function SiteDashboard() {
/> />
</div> </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 */} {/* Quick Actions */}
<ComponentCard title="Quick Actions" desc="Common site management tasks"> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -17,6 +17,8 @@ import SelectDropdown from '../../components/form/SelectDropdown';
import Label from '../../components/form/Label'; import Label from '../../components/form/Label';
import Checkbox from '../../components/form/input/Checkbox'; import Checkbox from '../../components/form/input/Checkbox';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { BoxCubeIcon } from '../../icons';
type TabType = 'content' | 'publishing' | 'images'; type TabType = 'content' | 'publishing' | 'images';
@@ -325,19 +327,16 @@ export default function ContentSettingsPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<PageMeta title="Content Settings" description="Configure your content generation settings" /> <PageMeta title="Content Settings" description="Configure your content generation settings" />
<PageHeader
{/* Page Header */} title={tabTitles[activeTab]}
<div className="mb-6"> description={
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1"> activeTab === 'content' ? 'Customize how your articles are written' :
Content Settings / {tabTitles[activeTab]} activeTab === 'publishing' ? 'Configure automatic publishing settings' :
</div> 'Set up AI image generation preferences'
<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"> badge={{ icon: <BoxCubeIcon />, color: 'blue' }}
{activeTab === 'content' && 'Customize how your articles are written'} parent="Content Settings"
{activeTab === 'publishing' && 'Configure automatic publishing settings'} />
{activeTab === 'images' && 'Set up AI image generation preferences'}
</p>
</div>
{/* Tab Content */} {/* Tab Content */}
<div className="mt-6"> <div className="mt-6">

117
immediate-udpates.md Normal file
View 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 Inarticle 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})

View 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

View 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