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

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
* Displays both site switcher and sector selector side by side with accent colors
*
* Dashboard Mode: Shows "All Sites" option, uses callback for filtering
* Module Mode: Standard site/sector selection
*/
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -15,10 +18,19 @@ import Button from '../ui/button/Button';
interface SiteAndSectorSelectorProps {
hideSectorSelector?: boolean;
/** Dashboard mode: show "All Sites" option */
showAllSitesOption?: boolean;
/** Current site filter for dashboard mode ('all' or site id) */
siteFilter?: 'all' | number;
/** Callback when site filter changes in dashboard mode */
onSiteFilterChange?: (value: 'all' | number) => void;
}
export default function SiteAndSectorSelector({
hideSectorSelector = false,
showAllSitesOption = false,
siteFilter,
onSiteFilterChange,
}: SiteAndSectorSelectorProps) {
const toast = useToast();
const navigate = useNavigate();
@@ -67,7 +79,22 @@ export default function SiteAndSectorSelector({
}
};
const handleSiteSelect = async (siteId: number) => {
const handleSiteSelect = async (siteId: number | 'all') => {
// Dashboard mode: use callback
if (showAllSitesOption && onSiteFilterChange) {
onSiteFilterChange(siteId);
setSitesOpen(false);
if (siteId !== 'all') {
const selectedSite = sites.find(s => s.id === siteId);
if (selectedSite) {
setActiveSite(selectedSite);
}
}
return;
}
// Module mode: standard site switching
if (siteId === 'all') return; // Should not happen in module mode
try {
await apiSetActiveSite(siteId);
const selectedSite = sites.find(s => s.id === siteId);
@@ -81,6 +108,24 @@ export default function SiteAndSectorSelector({
}
};
// Get display text based on mode
const getSiteDisplayText = () => {
if (sitesLoading) return 'Loading...';
if (showAllSitesOption && siteFilter === 'all') return 'All Sites';
if (showAllSitesOption && typeof siteFilter === 'number') {
return sites.find(s => s.id === siteFilter)?.name || activeSite?.name || 'Select Site';
}
return activeSite?.name || 'Select Site';
};
// Check if a site is selected
const isSiteSelected = (siteId: number | 'all') => {
if (showAllSitesOption) {
return siteFilter === siteId;
}
return siteId !== 'all' && activeSite?.id === siteId;
};
const handleSectorSelect = (sectorId: number | null) => {
if (sectorId === null) {
setActiveSector(null);
@@ -141,7 +186,7 @@ export default function SiteAndSectorSelector({
/>
</svg>
<span className="max-w-[150px] truncate">
{sitesLoading ? 'Loading...' : activeSite?.name || 'Select Site'}
{getSiteDisplayText()}
</span>
</span>
<svg
@@ -166,18 +211,44 @@ export default function SiteAndSectorSelector({
placement="bottom-left"
className="w-64 p-2"
>
{/* All Sites option - only in dashboard mode */}
{showAllSitesOption && (
<DropdownItem
onItemClick={() => handleSiteSelect('all')}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
isSiteSelected('all')
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">All Sites</span>
{isSiteSelected('all') && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
)}
{sites.map((site) => (
<DropdownItem
key={site.id}
onItemClick={() => handleSiteSelect(site.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSite?.id === site.id
isSiteSelected(site.id)
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{site.name}</span>
{activeSite?.id === site.id && (
{isSiteSelected(site.id) && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"

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 { Link, useNavigate } from "react-router-dom";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { Link } from "react-router-dom";
import {
useNotificationStore,
formatNotificationTime,
getNotificationColors,
NotificationType
} from "../../store/notificationStore";
import {
CheckCircleIcon,
AlertIcon,
BoltIcon,
FileTextIcon,
FileIcon,
GroupIcon,
} from "../../icons";
// Icon map for different notification categories/functions
const getNotificationIcon = (category: string, functionName?: string): React.ReactNode => {
if (functionName) {
switch (functionName) {
case 'auto_cluster':
return <GroupIcon className="w-5 h-5" />;
case 'generate_ideas':
return <BoltIcon className="w-5 h-5" />;
case 'generate_content':
return <FileTextIcon className="w-5 h-5" />;
case 'generate_images':
case 'generate_image_prompts':
return <FileIcon className="w-5 h-5" />;
default:
return <BoltIcon className="w-5 h-5" />;
}
}
switch (category) {
case 'ai_task':
return <BoltIcon className="w-5 h-5" />;
case 'system':
return <AlertIcon className="w-5 h-5" />;
default:
return <CheckCircleIcon className="w-5 h-5" />;
}
};
const getTypeIcon = (type: NotificationType): React.ReactNode => {
switch (type) {
case 'success':
return <CheckCircleIcon className="w-4 h-4" />;
case 'error':
case 'warning':
return <AlertIcon className="w-4 h-4" />;
default:
return <BoltIcon className="w-4 h-4" />;
}
};
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [notifying, setNotifying] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null);
const navigate = useNavigate();
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
removeNotification
} = useNotificationStore();
function toggleDropdown() {
setIsOpen(!isOpen);
@@ -18,22 +85,31 @@ export default function NotificationDropdown() {
const handleClick = () => {
toggleDropdown();
setNotifying(false);
};
const handleNotificationClick = (id: string, href?: string) => {
markAsRead(id);
closeDropdown();
if (href) {
navigate(href);
}
};
return (
<div className="relative">
<button
ref={buttonRef}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
onClick={handleClick}
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
>
<span
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${
!notifying ? "hidden" : "flex"
}`}
>
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
</span>
{/* Notification badge */}
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-[10px] font-semibold text-white">
{unreadCount > 9 ? '9+' : unreadCount}
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
</span>
)}
<svg
className="fill-current"
width="20"
@@ -49,335 +125,143 @@ export default function NotificationDropdown() {
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
anchorRef={buttonRef}
anchorRef={buttonRef as React.RefObject<HTMLElement>}
placement="bottom-right"
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
>
{/* Header */}
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
Notification
Notifications
{unreadCount > 0 && (
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
({unreadCount} new)
</span>
)}
</h5>
<button
onClick={toggleDropdown}
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
Mark all read
</button>
)}
<button
onClick={toggleDropdown}
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
aria-label="Close notifications"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
</button>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar">
{/* Example notification items */}
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-02.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
{/* Notification List */}
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
{notifications.length === 0 ? (
<li className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<BoltIcon className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
No notifications yet
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
AI task completions will appear here
</p>
</li>
) : (
notifications.map((notification) => {
const colors = getNotificationColors(notification.type);
const icon = getNotificationIcon(
notification.category,
notification.metadata?.functionName
);
return (
<li key={notification.id}>
<DropdownItem
onItemClick={() => handleNotificationClick(
notification.id,
notification.actionHref
)}
className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-white/5 ${
!notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
}`}
>
{/* Icon */}
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
<span className={colors.icon}>
{icon}
</span>
</span>
<span className="block">
<span className="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400 space-x-1">
<span className="font-medium text-gray-800 dark:text-white/90">
Terry Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
{/* Content */}
<span className="flex-1 min-w-0">
<span className="flex items-start justify-between gap-2">
<span className={`text-sm font-medium ${
!notification.read
? 'text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300'
}`}>
{notification.title}
</span>
{!notification.read && (
<span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
)}
</span>
<span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
{notification.message}
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>5 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-03.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Alena Franci
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>8 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-04.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Jocelyn Kenter
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>15 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
to="/"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-05.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 space-x-1 block text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Brandon Philips
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>1 hr ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
onItemClick={closeDropdown}
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-02.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Terry Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>5 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-03.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Alena Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>8 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-04.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Jocelyn Kenter
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>15 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-05.jpg"
alt="User"
className="overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Brandon Philips
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>1 hr ago</span>
</span>
</span>
</DropdownItem>
</li>
{/* Add more items as needed */}
<span className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{formatNotificationTime(notification.timestamp)}
</span>
{notification.actionLabel && notification.actionHref && (
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
{notification.actionLabel}
</span>
)}
</span>
</span>
</DropdownItem>
</li>
);
})
)}
</ul>
<Link
to="/"
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
View All Notifications
</Link>
{/* Footer */}
{notifications.length > 0 && (
<Link
to="/notifications"
onClick={closeDropdown}
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
View All Notifications
</Link>
)}
</Dropdown>
</div>
);

View File

@@ -1,6 +1,7 @@
/**
* Page Context - Shares current page info with header
* Allows pages to set title, parent module, badge for display in AppHeader
* Dashboard mode: enables "All Sites" option in site selector
*/
import React, { createContext, useContext, useState, ReactNode } from 'react';
@@ -11,6 +12,15 @@ interface PageInfo {
icon: ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
};
/** Completely hide site/sector selectors in app header */
hideSelectors?: boolean;
hideSectorSelector?: boolean; // Hide sector selector in app header (for dashboard)
/** Dashboard mode: show "All Sites" option in site selector */
showAllSitesOption?: boolean;
/** Current site filter for dashboard mode ('all' or site id) */
siteFilter?: 'all' | number;
/** Callback when site filter changes in dashboard mode */
onSiteFilterChange?: (value: 'all' | number) => void;
}
interface PageContextType {

View File

@@ -126,3 +126,7 @@ export { BoxIcon as TagIcon };
export { CloseIcon as XMarkIcon };
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause state
export { ArrowUpIcon as TrendingUpIcon }; // Trend up indicator
export { ArrowDownIcon as TrendingDownIcon }; // Trend down indicator
export { BoxCubeIcon as SettingsIcon }; // Settings/cog alias
export { InfoIcon as HelpCircleIcon }; // Help/question circle

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { usePageContext } from "../context/PageContext";
import { useSidebar } from "../context/SidebarContext";
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
@@ -8,8 +8,26 @@ import UserDropdown from "../components/header/UserDropdown";
import { HeaderMetrics } from "../components/header/HeaderMetrics";
import SearchModal from "../components/common/SearchModal";
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
import SingleSiteSelector from "../components/common/SingleSiteSelector";
import SiteWithAllSitesSelector from "../components/common/SiteWithAllSitesSelector";
import React from "react";
// Route patterns for selector visibility
const SITE_AND_SECTOR_ROUTES = [
'/planner', // All planner pages
'/writer', // All writer pages
'/setup/add-keywords', // Add keywords page
];
const SINGLE_SITE_ROUTES = [
'/automation',
'/account/content-settings', // Content settings and sub-pages
];
const SITE_WITH_ALL_SITES_ROUTES = [
'/', // Home dashboard only (exact match)
];
// Badge color mappings for light versions
const badgeColors: Record<string, { bg: string; light: string }> = {
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
@@ -31,6 +49,31 @@ const AppHeader: React.FC = () => {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { pageInfo } = usePageContext();
const { isExpanded, toggleSidebar } = useSidebar();
const location = useLocation();
// Determine which selector to show based on current route
const getSelectorType = (): 'site-and-sector' | 'single-site' | 'site-with-all' | 'none' => {
const path = location.pathname;
// Check for home dashboard (exact match)
if (path === '/' && pageInfo?.onSiteFilterChange) {
return 'site-with-all';
}
// Check for site + sector selector routes
if (SITE_AND_SECTOR_ROUTES.some(route => path.startsWith(route))) {
return 'site-and-sector';
}
// Check for single site selector routes
if (SINGLE_SITE_ROUTES.some(route => path.startsWith(route))) {
return 'single-site';
}
return 'none';
};
const selectorType = getSelectorType();
const toggleApplicationMenu = () => {
setApplicationMenuOpen(!isApplicationMenuOpen);
@@ -117,10 +160,25 @@ const AppHeader: React.FC = () => {
{/* Header Metrics */}
<HeaderMetrics />
{/* Site and Sector Selector - Desktop */}
<div className="hidden lg:flex items-center">
<SiteAndSectorSelector />
</div>
{/* Site/Sector Selector - Conditional based on route */}
{selectorType === 'site-and-sector' && (
<div className="hidden lg:flex items-center">
<SiteAndSectorSelector />
</div>
)}
{selectorType === 'single-site' && (
<div className="hidden lg:flex items-center">
<SingleSiteSelector />
</div>
)}
{selectorType === 'site-with-all' && pageInfo?.onSiteFilterChange && (
<div className="hidden lg:flex items-center">
<SiteWithAllSitesSelector
siteFilter={pageInfo.siteFilter}
onSiteFilterChange={pageInfo.onSiteFilterChange}
/>
</div>
)}
{/* Search Icon */}
<button

View File

@@ -19,6 +19,7 @@ import ConfigModal from '../../components/Automation/ConfigModal';
import RunHistory from '../../components/Automation/RunHistory';
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ComponentCard from '../../components/common/ComponentCard';
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
@@ -379,49 +380,34 @@ const AutomationPage: React.FC = () => {
return (
<>
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" />
<PageHeader
title="Automation"
description="Automatically create and publish content on your schedule"
badge={{ icon: <BoltIcon />, color: 'teal' }}
parent="Automation"
/>
<div className="space-y-6">
{/* Header */}
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600">
<BoltIcon className="text-white size-5" />
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
<div className="flex justify-center">
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-500'}`}>
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-4 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-4 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-4 text-white" /> : <BoltIcon className="size-4 text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
{currentRun?.status === 'paused' && 'Paused'}
{!currentRun && totalPending > 0 && 'Ready to Run'}
{!currentRun && totalPending === 0 && 'No Items Pending'}
</div>
<div>
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automation</h2>
{activeSite && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
</p>
)}
<div className="text-xs text-slate-600 dark:text-gray-400 truncate">
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
</div>
</div>
</div>
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
<div className="hidden sm:flex absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-500'}`}>
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-4 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-4 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-4 text-white" /> : <BoltIcon className="size-4 text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
{currentRun?.status === 'paused' && 'Paused'}
{!currentRun && totalPending > 0 && 'Ready to Run'}
{!currentRun && totalPending === 0 && 'No Items Pending'}
</div>
<div className="text-xs text-slate-600 dark:text-gray-400 truncate">
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
</div>
</div>
</div>
</div>
<DebugSiteSelector />
</div>
{/* Compact Schedule & Controls Panel */}

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,15 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ComponentCard from '../../components/common/ComponentCard';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI, fetchSiteSectors } from '../../services/api';
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
import { integrationApi } from '../../services/integration.api';
import SiteConfigWidget from '../../components/dashboard/SiteConfigWidget';
import OperationsCostsWidget from '../../components/dashboard/OperationsCostsWidget';
import CreditAvailabilityWidget from '../../components/dashboard/CreditAvailabilityWidget';
import { useBillingStore } from '../../store/billingStore';
import {
FileIcon,
PlugInIcon,
@@ -21,7 +23,6 @@ import {
BoltIcon,
PageIcon,
ArrowRightIcon,
ArrowUpIcon
} from '../../icons';
interface Site {
@@ -42,28 +43,46 @@ interface Site {
interface SiteSetupState {
hasIndustry: boolean;
hasSectors: boolean;
sectorsCount: number;
hasWordPressIntegration: boolean;
hasKeywords: boolean;
keywordsCount: number;
hasAuthorProfiles: boolean;
authorProfilesCount: number;
}
interface OperationStat {
type: 'clustering' | 'ideas' | 'content' | 'images';
count: number;
creditsUsed: number;
avgCreditsPerOp: number;
}
export default function SiteDashboard() {
const { id: siteId } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const { balance, loadBalance } = useBillingStore();
const [site, setSite] = useState<Site | null>(null);
const [setupState, setSetupState] = useState<SiteSetupState>({
hasIndustry: false,
hasSectors: false,
sectorsCount: 0,
hasWordPressIntegration: false,
hasKeywords: false,
keywordsCount: 0,
hasAuthorProfiles: false,
authorProfilesCount: 0,
});
const [operations, setOperations] = useState<OperationStat[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (siteId) {
loadSiteData();
loadBalance();
}
}, [siteId]);
}, [siteId, loadBalance]);
const loadSiteData = async () => {
try {
@@ -79,9 +98,11 @@ export default function SiteDashboard() {
// Load sectors
let hasSectors = false;
let sectorsCount = 0;
try {
const sectors = await fetchSiteSectors(Number(siteId));
hasSectors = sectors && sectors.length > 0;
sectorsCount = sectors?.length || 0;
} catch (err) {
console.log('Could not load sectors');
}
@@ -97,20 +118,47 @@ export default function SiteDashboard() {
// Check keywords - try to load keywords for this site
let hasKeywords = false;
let keywordsCount = 0;
try {
const { fetchKeywords } = await import('../../services/api');
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
keywordsCount = keywordsData?.count || 0;
} catch (err) {
// No keywords is fine
}
// Check author profiles
let hasAuthorProfiles = false;
let authorProfilesCount = 0;
try {
const authorsData = await fetchAPI(`/v1/thinker/author-profiles/?site_id=${siteId}&page_size=1`);
hasAuthorProfiles = authorsData?.count > 0;
authorProfilesCount = authorsData?.count || 0;
} catch (err) {
// No profiles is fine
}
setSetupState({
hasIndustry,
hasSectors,
sectorsCount,
hasWordPressIntegration,
hasKeywords,
keywordsCount,
hasAuthorProfiles,
authorProfilesCount,
});
// Load operation stats (mock data for now - would come from backend)
// In real implementation, fetch from /api/v1/dashboard/site/{siteId}/operations/
const mockOperations: OperationStat[] = [
{ type: 'clustering', count: 8, creditsUsed: 80, avgCreditsPerOp: 10 },
{ type: 'ideas', count: 12, creditsUsed: 24, avgCreditsPerOp: 2 },
{ type: 'content', count: 28, creditsUsed: 1400, avgCreditsPerOp: 50 },
{ type: 'images', count: 45, creditsUsed: 225, avgCreditsPerOp: 5 },
];
setOperations(mockOperations);
}
} catch (error: any) {
toast.error(`Failed to load site data: ${error.message}`);
@@ -185,6 +233,28 @@ export default function SiteDashboard() {
/>
</div>
{/* Site Insights - 3 Column Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<SiteConfigWidget
setupState={{
hasIndustry: setupState.hasIndustry,
sectorsCount: setupState.sectorsCount,
hasWordPressIntegration: setupState.hasWordPressIntegration,
keywordsCount: setupState.keywordsCount,
authorProfilesCount: setupState.authorProfilesCount
}}
siteId={Number(siteId)}
/>
<OperationsCostsWidget operations={operations} siteId={Number(siteId)} />
<CreditAvailabilityWidget
availableCredits={balance?.credits_remaining ?? 0}
totalCredits={balance?.plan_credits_per_month ?? 0}
loading={loading}
/>
</div>
{/* Quick Actions */}
<ComponentCard title="Quick Actions" desc="Common site management tasks">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

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