page adn app header mods

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 04:09:05 +00:00
parent e5959c3e72
commit fd6e7eb2dd
14 changed files with 494 additions and 547 deletions

View File

@@ -1,20 +1,18 @@
/** /**
* Standardized Page Header Component * Standardized Page Header Component
* Used across all Planner and Writer module pages * Simplified version - Site/sector selector moved to AppHeader
* Includes: Page title, last updated, site/sector info, and selectors * Just shows: breadcrumb (inline), page title with badge, description
*/ */
import React, { ReactNode, useEffect, useRef } from 'react'; import React, { ReactNode, useEffect, useRef } from 'react';
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import SiteAndSectorSelector from './SiteAndSectorSelector';
import { trackLoading } from './LoadingStateMonitor'; import { trackLoading } from './LoadingStateMonitor';
import { useErrorHandler } from '../../hooks/useErrorHandler'; import { useErrorHandler } from '../../hooks/useErrorHandler';
import { WorkflowInsights, WorkflowInsight } from './WorkflowInsights';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
description?: string; // Optional page description shown below title description?: string;
breadcrumb?: string; // Optional breadcrumb text (e.g., "Thinker / Prompts") breadcrumb?: string;
lastUpdated?: Date; lastUpdated?: Date;
showRefresh?: boolean; showRefresh?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
@@ -23,9 +21,11 @@ interface PageHeaderProps {
icon: ReactNode; icon: ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo'; color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo';
}; };
hideSiteSector?: boolean; // Hide site/sector selector and info for global pages hideSiteSector?: boolean;
navigation?: ReactNode; // Module navigation tabs navigation?: ReactNode; // Kept for backwards compat but not rendered
workflowInsights?: WorkflowInsight[]; // Actionable insights for current page workflowInsights?: any[]; // Kept for backwards compat but not rendered
/** Right-side actions slot */
actions?: ReactNode;
} }
export default function PageHeader({ export default function PageHeader({
@@ -38,47 +38,35 @@ export default function PageHeader({
className = "", className = "",
badge, badge,
hideSiteSector = false, hideSiteSector = false,
navigation, actions,
workflowInsights,
}: PageHeaderProps) { }: PageHeaderProps) {
const { activeSite } = useSiteStore(); const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore(); const { loadSectorsForSite } = useSectorStore();
const { addError } = useErrorHandler('PageHeader'); const { addError } = useErrorHandler('PageHeader');
const lastSiteId = useRef<number | null>(null); const lastSiteId = useRef<number | null>(null);
const isLoadingSector = useRef(false); const isLoadingSector = useRef(false);
// Load sectors when active site changes - only for pages that need site/sector context // Load sectors when active site changes
useEffect(() => { useEffect(() => {
// Skip sector loading for pages that hide site/sector selector (account/billing pages)
if (hideSiteSector) return; if (hideSiteSector) return;
const currentSiteId = activeSite?.id ?? null; const currentSiteId = activeSite?.id ?? null;
// Only load if:
// 1. We have a site ID
// 2. The site is active (inactive sites can't have accessible sectors)
// 3. It's different from the last one we loaded
// 4. We're not already loading
if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) { if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) {
lastSiteId.current = currentSiteId; lastSiteId.current = currentSiteId;
isLoadingSector.current = true; isLoadingSector.current = true;
trackLoading('sector-loading', true); trackLoading('sector-loading', true);
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (isLoadingSector.current) { if (isLoadingSector.current) {
console.error('PageHeader: Sector loading timeout after 35 seconds');
trackLoading('sector-loading', false); trackLoading('sector-loading', false);
isLoadingSector.current = false; isLoadingSector.current = false;
addError(new Error('Sector loading timeout - check network connection'), 'PageHeader.loadSectorsForSite'); addError(new Error('Sector loading timeout'), 'PageHeader.loadSectorsForSite');
} }
}, 35000); }, 35000);
loadSectorsForSite(currentSiteId) loadSectorsForSite(currentSiteId)
.catch((error) => { .catch((error) => {
// Don't log 403/404 errors as they're expected for inactive sites
if (error.status !== 403 && error.status !== 404) { if (error.status !== 403 && error.status !== 404) {
console.error('PageHeader: Error loading sectors:', error);
addError(error, 'PageHeader.loadSectorsForSite'); addError(error, 'PageHeader.loadSectorsForSite');
} }
}) })
@@ -88,7 +76,6 @@ export default function PageHeader({
isLoadingSector.current = false; isLoadingSector.current = false;
}); });
} else if (currentSiteId && !activeSite?.is_active) { } else if (currentSiteId && !activeSite?.is_active) {
// Site is inactive - clear sectors and reset lastSiteId
lastSiteId.current = null; lastSiteId.current = null;
const { useSectorStore } = require('../../store/sectorStore'); const { useSectorStore } = require('../../store/sectorStore');
useSectorStore.getState().clearActiveSector(); useSectorStore.getState().clearActiveSector();
@@ -105,98 +92,47 @@ export default function PageHeader({
}; };
return ( return (
<div className={`flex flex-col gap-3 ${className}`}> <div className={`flex items-center justify-between gap-4 ${className}`}>
{/* Breadcrumb */} {/* Left: Breadcrumb + Badge + Title */}
{breadcrumb && ( <div className="flex items-center gap-3 min-w-0">
<div className="text-sm text-gray-500 dark:text-gray-400"> {breadcrumb && (
{breadcrumb} <>
</div> <span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">{breadcrumb}</span>
)} <span className="text-gray-300 dark:text-gray-600">/</span>
</>
{/* Main header row - single row with 3 sections */} )}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> {badge && (
{/* Left side: Title, badge, and site/sector info */} <div className={`flex items-center justify-center w-8 h-8 rounded-lg ${badgeColors[badge.color]} flex-shrink-0`}>
<div className="flex-shrink-0"> {badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
<div className="flex items-center gap-4"> ? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-4' })
{badge && ( : badge.icon}
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
: badge.icon}
</div>
)}
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-xl">{description}</p>
)}
{!hideSiteSector && (
<div className="flex items-center gap-3 mt-1">
{lastUpdated && (
<>
<p className="text-sm text-gray-500 dark:text-gray-400">
Last updated: {lastUpdated.toLocaleTimeString()}
</p>
</>
)}
{activeSite && (
<>
{lastUpdated && <span className="text-sm text-gray-400 dark:text-gray-600"></span>}
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
</p>
</>
)}
{activeSector && (
<>
<span className="text-sm text-gray-400 dark:text-gray-600"></span>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Sector: <span className="text-brand-600 dark:text-brand-400">{activeSector.name}</span>
</p>
</>
)}
{!activeSector && activeSite && (
<>
<span className="text-sm text-gray-400 dark:text-gray-600"></span>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Sector: <span className="text-brand-600 dark:text-brand-400">All Sectors</span>
</p>
</>
)}
</div>
)}
{hideSiteSector && lastUpdated && (
<div className="flex items-center gap-3 mt-1">
<p className="text-sm text-gray-500 dark:text-gray-400">
Last updated: {lastUpdated.toLocaleTimeString()}
</p>
</div>
)}
</div>
{/* Middle: Workflow insights - takes available space */}
{workflowInsights && workflowInsights.length > 0 && (
<div className="flex-1 flex items-center justify-center px-6">
<WorkflowInsights insights={workflowInsights} />
</div> </div>
)} )}
<div className="min-w-0">
{/* Right side: Navigation bar stacked above site/sector selector */} <h1 className="text-xl font-semibold text-gray-800 dark:text-white truncate">{title}</h1>
<div className="flex flex-col items-end gap-3 flex-shrink-0"> {description && (
{navigation && <div>{navigation}</div>} <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{description}</p>
<div className="flex items-center gap-3"> )}
{!hideSiteSector && <SiteAndSectorSelector />}
{showRefresh && onRefresh && (
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
>
Refresh
</button>
)}
</div>
</div> </div>
</div> </div>
{/* Right: Actions */}
<div className="flex items-center gap-3 flex-shrink-0">
{lastUpdated && (
<span className="text-xs text-gray-400 dark:text-gray-500 hidden sm:block">
Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
{showRefresh && onRefresh && (
<button
onClick={onRefresh}
className="px-3 py-1.5 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
>
Refresh
</button>
)}
{actions}
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,136 @@
/**
* Search Modal - Global search modal triggered by icon or Cmd+K
*/
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../ui/modal';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
interface SearchResult {
title: string;
path: string;
type: 'page' | 'action';
icon?: string;
}
const SEARCH_ITEMS: SearchResult[] = [
// Workflow
{ title: 'Keywords', path: '/planner/keywords', type: 'page' },
{ title: 'Clusters', path: '/planner/clusters', type: 'page' },
{ title: 'Ideas', path: '/planner/ideas', type: 'page' },
{ title: 'Queue', path: '/writer/tasks', type: 'page' },
{ title: 'Drafts', path: '/writer/content', type: 'page' },
{ title: 'Images', path: '/writer/images', type: 'page' },
{ title: 'Review', path: '/writer/review', type: 'page' },
{ title: 'Published', path: '/writer/published', type: 'page' },
// Setup
{ title: 'Sites', path: '/sites', type: 'page' },
{ title: 'Add Keywords', path: '/add-keywords', type: 'page' },
{ title: 'Content Settings', path: '/account/content-settings', type: 'page' },
{ title: 'Prompts', path: '/thinker/prompts', type: 'page' },
{ title: 'Author Profiles', path: '/thinker/author-profiles', type: 'page' },
// Account
{ title: 'Account Settings', path: '/account/settings', type: 'page' },
{ title: 'Plans & Billing', path: '/account/plans', type: 'page' },
{ title: 'Usage Analytics', path: '/account/usage', type: 'page' },
// Help
{ title: 'Help & Support', path: '/help', type: 'page' },
];
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const filteredResults = query.length > 0
? SEARCH_ITEMS.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase())
)
: SEARCH_ITEMS.slice(0, 8);
useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filteredResults[selectedIndex]) {
navigate(filteredResults[selectedIndex].path);
onClose();
}
};
const handleSelect = (result: SearchResult) => {
navigate(result.path);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose} className="sm:max-w-lg">
<div className="p-0">
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search pages..."
className="w-full pl-12 pr-4 py-4 text-lg border-b border-gray-200 dark:border-gray-700 bg-transparent focus:outline-none dark:text-white"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-gray-400 hidden sm:block">
ESC to close
</span>
</div>
<div className="max-h-80 overflow-y-auto py-2">
{filteredResults.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">
No results found for "{query}"
</div>
) : (
filteredResults.map((result, index) => (
<button
key={result.path}
onClick={() => handleSelect(result)}
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${
index === selectedIndex
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium">{result.title}</span>
</button>
))
)}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,48 @@
/**
* Page Context - Shares current page info with header
* Allows pages to set title, breadcrumb, badge for display in AppHeader
*/
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface PageInfo {
title: string;
breadcrumb?: string;
badge?: {
icon: ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo';
};
}
interface PageContextType {
pageInfo: PageInfo | null;
setPageInfo: (info: PageInfo | null) => void;
}
const PageContext = createContext<PageContextType | undefined>(undefined);
export function PageProvider({ children }: { children: ReactNode }) {
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
return (
<PageContext.Provider value={{ pageInfo, setPageInfo }}>
{children}
</PageContext.Provider>
);
}
export function usePageContext() {
const context = useContext(PageContext);
if (context === undefined) {
throw new Error('usePageContext must be used within a PageProvider');
}
return context;
}
export function usePage(info: PageInfo) {
const { setPageInfo } = usePageContext();
React.useEffect(() => {
setPageInfo(info);
return () => setPageInfo(null);
}, [info.title, info.breadcrumb]);
}

View File

@@ -1,14 +1,16 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { ThemeToggleButton } from "../components/common/ThemeToggleButton"; import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
import NotificationDropdown from "../components/header/NotificationDropdown"; import NotificationDropdown from "../components/header/NotificationDropdown";
import UserDropdown from "../components/header/UserDropdown"; import UserDropdown from "../components/header/UserDropdown";
import { HeaderMetrics } from "../components/header/HeaderMetrics"; import { HeaderMetrics } from "../components/header/HeaderMetrics";
import SearchModal from "../components/common/SearchModal";
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
const AppHeader: React.FC = () => { const AppHeader: React.FC = () => {
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false); const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar(); const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
@@ -24,153 +26,100 @@ const AppHeader: React.FC = () => {
setApplicationMenuOpen(!isApplicationMenuOpen); setApplicationMenuOpen(!isApplicationMenuOpen);
}; };
const inputRef = useRef<HTMLInputElement>(null); // Keyboard shortcut for search
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") { if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault(); event.preventDefault();
inputRef.current?.focus(); setIsSearchOpen(true);
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, []); }, []);
return ( return (
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b"> <>
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6"> <header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4"> <div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<button <div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border" {/* Sidebar Toggle */}
onClick={handleToggle} <button
aria-label="Toggle Sidebar" className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
> onClick={handleToggle}
{isMobileOpen ? ( aria-label="Toggle Sidebar"
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
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>
) : (
<svg
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
)}
{/* Cross Icon */}
</button>
<Link to="/" className="lg:hidden">
<img
className="dark:hidden"
src="./images/logo/logo.svg"
alt="Logo"
/>
<img
className="hidden dark:block"
src="./images/logo/logo-dark.svg"
alt="Logo"
/>
</Link>
<button
onClick={toggleApplicationMenu}
className="flex items-center justify-center w-10 h-10 text-gray-700 rounded-lg z-99999 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 lg:hidden"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<path {isMobileOpen ? (
fillRule="evenodd" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
clipRule="evenodd" <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" />
d="M5.99902 10.4951C6.82745 10.4951 7.49902 11.1667 7.49902 11.9951V12.0051C7.49902 12.8335 6.82745 13.5051 5.99902 13.5051C5.1706 13.5051 4.49902 12.8335 4.49902 12.0051V11.9951C4.49902 11.1667 5.1706 10.4951 5.99902 10.4951ZM17.999 10.4951C18.8275 10.4951 19.499 11.1667 19.499 11.9951V12.0051C19.499 12.8335 18.8275 13.5051 17.999 13.5051C17.1706 13.5051 16.499 12.8335 16.499 12.0051V11.9951C16.499 11.1667 17.1706 10.4951 17.999 10.4951ZM13.499 11.9951C13.499 11.1667 12.8275 10.4951 11.999 10.4951C11.1706 10.4951 10.499 11.1667 10.499 11.9951V12.0051C10.499 12.8335 11.1706 13.5051 11.999 13.5051C12.8275 13.5051 13.499 12.8335 13.499 12.0051V11.9951Z" </svg>
fill="currentColor" ) : (
/> <svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
</svg> <path fillRule="evenodd" clipRule="evenodd" d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z" fill="currentColor" />
</button> </svg>
)}
</button>
<div className="hidden lg:block"> {/* Mobile Logo */}
<form> <Link to="/" className="lg:hidden">
<div className="relative"> <img className="dark:hidden" src="./images/logo/logo.svg" alt="Logo" />
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2"> <img className="hidden dark:block" src="./images/logo/logo-dark.svg" alt="Logo" />
<svg </Link>
className="fill-gray-500 dark:fill-gray-400"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
fill=""
/>
</svg>
</span>
<input
ref={inputRef}
type="text"
placeholder="Search or type command..."
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
/>
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400"> {/* Mobile Menu Toggle */}
<span> </span> <button
<span> K </span> onClick={toggleApplicationMenu}
</button> className="flex items-center justify-center w-10 h-10 text-gray-700 rounded-lg z-99999 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 lg:hidden"
</div> >
</form> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M5.99902 10.4951C6.82745 10.4951 7.49902 11.1667 7.49902 11.9951V12.0051C7.49902 12.8335 6.82745 13.5051 5.99902 13.5051C5.1706 13.5051 4.49902 12.8335 4.49902 12.0051V11.9951C4.49902 11.1667 5.1706 10.4951 5.99902 10.4951ZM17.999 10.4951C18.8275 10.4951 19.499 11.1667 19.499 11.9951V12.0051C19.499 12.8335 18.8275 13.5051 17.999 13.5051C17.1706 13.5051 16.499 12.8335 16.499 12.0051V11.9951C16.499 11.1667 17.1706 10.4951 17.999 10.4951ZM13.499 11.9951C13.499 11.1667 12.8275 10.4951 11.999 10.4951C11.1706 10.4951 10.499 11.1667 10.499 11.9951V12.0051C10.499 12.8335 11.1706 13.5051 11.999 13.5051C12.8275 13.5051 13.499 12.8335 13.499 12.0051V11.9951Z" fill="currentColor" />
</svg>
</button>
{/* Site and Sector Selector - Desktop */}
<div className="hidden lg:flex items-center gap-4 ml-4">
<SiteAndSectorSelector />
</div>
</div>
{/* Right side actions */}
<div
className={`${
isApplicationMenuOpen ? "flex" : "hidden"
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
>
<div className="flex items-center gap-2 2xsm:gap-3">
{/* Header Metrics */}
<HeaderMetrics />
{/* Search Icon */}
<button
onClick={() => setIsSearchOpen(true)}
className="flex items-center justify-center w-10 h-10 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition-colors"
title="Search (⌘K)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
{/* Dark Mode Toggler */}
<ThemeToggleButton />
{/* Notifications */}
<NotificationDropdown />
</div>
{/* User Menu */}
<UserDropdown />
</div> </div>
</div> </div>
<div </header>
className={`${
isApplicationMenuOpen ? "flex" : "hidden" {/* Search Modal */}
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`} <SearchModal isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
> </>
<div className="flex items-center gap-2 2xsm:gap-3">
{/* <!-- Header Metrics (conditional) --> */}
<HeaderMetrics />
{/* <!-- Dark Mode Toggler --> */}
<ThemeToggleButton />
{/* <!-- Notification Menu Area --> */}
<NotificationDropdown />
{/* <!-- Notification Menu Area --> */}
</div>
{/* <!-- User Area --> */}
<UserDropdown />
</div>
</div>
</header>
); );
}; };

View File

@@ -4,6 +4,7 @@
*/ */
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchClusters, fetchClusters,
@@ -28,7 +29,6 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty'; import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
export default function Clusters() { export default function Clusters() {
const toast = useToast(); const toast = useToast();
@@ -76,61 +76,6 @@ export default function Clusters() {
const progressModal = useProgressModal(); const progressModal = useProgressModal();
const hasReloadedRef = useRef(false); const hasReloadedRef = useRef(false);
// Calculate workflow insights
const workflowInsights: WorkflowInsight[] = useMemo(() => {
const insights: WorkflowInsight[] = [];
const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
const totalIdeas = clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0);
const emptyClusters = clusters.filter(c => (c.keywords_count || 0) === 0).length;
const thinClusters = clusters.filter(c => (c.keywords_count || 0) > 0 && (c.keywords_count || 0) < 3).length;
const readyForGeneration = clustersWithIdeas;
const generationRate = totalCount > 0 ? Math.round((readyForGeneration / totalCount) * 100) : 0;
if (totalCount === 0) {
insights.push({
type: 'info',
message: 'Create clusters to organize keywords into topical groups for better content planning',
});
return insights;
}
// Content generation readiness
if (generationRate < 30) {
insights.push({
type: 'warning',
message: `Only ${generationRate}% of clusters have content ideas - Generate ideas to unlock content pipeline`,
});
} else if (generationRate >= 70) {
insights.push({
type: 'success',
message: `${generationRate}% of clusters have ideas (${totalIdeas} total) - Strong content pipeline ready`,
});
}
// Empty or thin clusters
if (emptyClusters > 0) {
insights.push({
type: 'warning',
message: `${emptyClusters} clusters have no keywords - Map keywords or delete unused clusters`,
});
} else if (thinClusters > 2) {
insights.push({
type: 'info',
message: `${thinClusters} clusters have fewer than 3 keywords - Consider adding more related keywords for better coverage`,
});
}
// Actionable next step
if (totalIdeas === 0) {
insights.push({
type: 'action',
message: 'Select clusters and use Auto-Generate Ideas to create content briefs',
});
}
return insights;
}, [clusters, totalCount]);
// Load clusters - wrapped in useCallback to prevent infinite loops // Load clusters - wrapped in useCallback to prevent infinite loops
const loadClusters = useCallback(async () => { const loadClusters = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -445,10 +390,20 @@ export default function Clusters() {
<> <>
<PageHeader <PageHeader
title="Clusters" title="Clusters"
description="Keyword groups organized by topic. Generate content ideas from clusters to build topical authority." description="Group keywords into topic clusters"
badge={{ icon: <GroupIcon />, color: 'purple' }} badge={{ icon: <GroupIcon />, color: 'purple' }}
breadcrumb="Planner / Clusters" breadcrumb="Planner"
workflowInsights={workflowInsights} actions={
<Link
to="/planner/ideas"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
Generate Ideas
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -4,6 +4,7 @@
*/ */
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContentIdeas, fetchContentIdeas,
@@ -24,12 +25,12 @@ import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal'; import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon } from '../../icons'; import { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon } from '../../icons';
import { LightBulbIcon } from '@heroicons/react/24/outline';
import { createIdeasPageConfig } from '../../config/pages/ideas.config'; import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
export default function Ideas() { export default function Ideas() {
const toast = useToast(); const toast = useToast();
@@ -77,57 +78,6 @@ export default function Ideas() {
// Progress modal for AI functions // Progress modal for AI functions
const progressModal = useProgressModal(); const progressModal = useProgressModal();
// Calculate workflow insights
const workflowInsights: WorkflowInsight[] = useMemo(() => {
const insights: WorkflowInsight[] = [];
const newCount = ideas.filter(i => i.status === 'new').length;
const queuedCount = ideas.filter(i => i.status === 'queued').length;
const completedCount = ideas.filter(i => i.status === 'completed').length;
const queueActivationRate = totalCount > 0 ? Math.round((queuedCount / totalCount) * 100) : 0;
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
if (totalCount === 0) {
insights.push({
type: 'info',
message: 'Generate ideas from your keyword clusters to build your content pipeline',
});
return insights;
}
// Queue activation insights
if (newCount > 0 && queuedCount === 0) {
insights.push({
type: 'warning',
message: `${newCount} new ideas waiting - Queue them to activate the content pipeline`,
});
} else if (queueActivationRate > 0 && queueActivationRate < 40) {
insights.push({
type: 'info',
message: `${queueActivationRate}% of ideas queued (${queuedCount} ideas) - Queue more ideas to maintain steady content flow`,
});
} else if (queuedCount > 0) {
insights.push({
type: 'success',
message: `${queuedCount} ideas in queue - Content pipeline is active and ready for task generation`,
});
}
// Completion velocity
if (completionRate >= 50) {
insights.push({
type: 'success',
message: `Strong completion rate (${completionRate}%) - ${completedCount} ideas converted to content`,
});
} else if (completionRate > 0) {
insights.push({
type: 'info',
message: `${completedCount} ideas completed (${completionRate}%) - Continue queuing ideas to grow content library`,
});
}
return insights;
}, [ideas, totalCount]);
// Load clusters for filter dropdown // Load clusters for filter dropdown
useEffect(() => { useEffect(() => {
const loadClusters = async () => { const loadClusters = async () => {
@@ -351,10 +301,20 @@ export default function Ideas() {
<> <>
<PageHeader <PageHeader
title="Ideas" title="Ideas"
description="AI-generated content ideas with titles, outlines, and target keywords. Queue ideas to start content generation." description="Content ideas generated from keywords"
badge={{ icon: <BoltIcon />, color: 'orange' }} badge={{ icon: <LightBulbIcon />, color: 'yellow' }}
breadcrumb="Planner / Ideas" breadcrumb="Planner"
workflowInsights={workflowInsights} actions={
<Link
to="/writer/queue"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
Start Writing
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -5,6 +5,7 @@
*/ */
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchKeywords, fetchKeywords,
@@ -346,6 +347,18 @@ export default function Keywords() {
} }
}, [toast, activeSector, loadKeywords, progressModal, keywords]); }, [toast, activeSector, loadKeywords, progressModal, keywords]);
// Quick auto-cluster unclustered keywords (for Next Step button)
const handleAutoCluster = useCallback(async () => {
const unclusteredIds = keywords.filter(k => !k.cluster_id).map(k => k.id);
if (unclusteredIds.length === 0) {
toast.info('All keywords are already clustered');
return;
}
// Limit to 50 keywords
const idsToCluster = unclusteredIds.slice(0, 50);
await handleBulkAction('auto_cluster', idsToCluster.map(String));
}, [keywords, handleBulkAction, toast]);
// Reset reload flag when modal closes or opens // Reset reload flag when modal closes or opens
useEffect(() => { useEffect(() => {
if (!progressModal.isOpen) { if (!progressModal.isOpen) {
@@ -481,63 +494,32 @@ export default function Keywords() {
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]); }, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
// Calculate workflow insights based on UX doc principles // Calculate workflow insights based on UX doc principles
const workflowInsights = useMemo(() => { const workflowStats = useMemo(() => {
const insights = [];
const clusteredCount = keywords.filter(k => k.cluster_id).length; const clusteredCount = keywords.filter(k => k.cluster_id).length;
const unclusteredCount = totalCount - clusteredCount; const unclusteredCount = totalCount - clusteredCount;
const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0; const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0;
return {
total: totalCount,
clustered: clusteredCount,
unclustered: unclusteredCount,
readiness: pipelineReadiness,
};
}, [keywords, totalCount]);
// Determine next step action
const nextStep = useMemo(() => {
if (totalCount === 0) { if (totalCount === 0) {
insights.push({ return { label: 'Import Keywords', path: '/add-keywords', disabled: false };
type: 'info' as const,
message: 'Import keywords to begin building your content strategy and unlock SEO opportunities',
});
return insights;
} }
if (workflowStats.unclustered >= 5) {
// Pipeline Readiness Score insight return { label: 'Auto-Cluster', action: 'cluster', disabled: false };
if (pipelineReadiness < 30) {
insights.push({
type: 'warning' as const,
message: `Pipeline readiness at ${pipelineReadiness}% - Most keywords need clustering before content ideation can begin`,
});
} else if (pipelineReadiness < 60) {
insights.push({
type: 'info' as const,
message: `Pipeline readiness at ${pipelineReadiness}% - Clustering progress is moderate, continue organizing keywords`,
});
} else if (pipelineReadiness >= 85) {
insights.push({
type: 'success' as const,
message: `Excellent pipeline readiness (${pipelineReadiness}%) - Ready for content ideation phase`,
});
} }
if (workflowStats.clustered > 0) {
// Clustering Potential (minimum batch size check) return { label: 'Generate Ideas', path: '/planner/ideas', disabled: false };
if (unclusteredCount >= 5) {
insights.push({
type: 'action' as const,
message: `${unclusteredCount} keywords available for auto-clustering (minimum batch size met)`,
});
} else if (unclusteredCount > 0 && unclusteredCount < 5) {
insights.push({
type: 'info' as const,
message: `${unclusteredCount} unclustered keywords - Need ${5 - unclusteredCount} more to run auto-cluster`,
});
} }
return { label: 'Add More Keywords', path: '/add-keywords', disabled: false };
// Coverage Gaps - thin clusters that need more research }, [totalCount, workflowStats]);
const thinClusters = clusters.filter(c => (c.keywords_count || 0) === 1);
if (thinClusters.length > 3) {
const thinVolume = thinClusters.reduce((sum, c) => sum + (c.volume || 0), 0);
insights.push({
type: 'warning' as const,
message: `${thinClusters.length} clusters have only 1 keyword each (${thinVolume.toLocaleString()} monthly volume) - Consider expanding research`,
});
}
return insights;
}, [keywords, totalCount, clusters]);
// Handle create/edit // Handle create/edit
const handleSave = async () => { const handleSave = async () => {
@@ -612,10 +594,37 @@ export default function Keywords() {
<> <>
<PageHeader <PageHeader
title="Keywords" title="Keywords"
description="Your target search terms organized for content creation. Import, cluster, and transform into content ideas." description="Your target search terms organized for content creation"
badge={{ icon: <ListIcon />, color: 'green' }} badge={{ icon: <ListIcon />, color: 'green' }}
breadcrumb="Planner / Keywords" breadcrumb="Planner"
workflowInsights={workflowInsights} actions={
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400 hidden md:block">
{workflowStats.clustered}/{workflowStats.total} clustered
</span>
{nextStep.path ? (
<Link
to={nextStep.path}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
{nextStep.label}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
) : nextStep.action === 'cluster' ? (
<button
onClick={handleAutoCluster}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
{nextStep.label}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : null}
</div>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -150,6 +150,7 @@ export default function SiteDashboard() {
<PageHeader <PageHeader
title={site.name} title={site.name}
badge={{ icon: <GridIcon />, color: 'blue' }} badge={{ icon: <GridIcon />, color: 'blue' }}
breadcrumb="Sites / Dashboard"
/> />
{/* Site Info */} {/* Site Info */}
@@ -267,6 +268,8 @@ export default function SiteDashboard() {
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
No recent activity No recent activity
</p> </p>
</Card>
</div>
); );
} }

View File

@@ -14,7 +14,7 @@ import {
bulkDeleteContent, bulkDeleteContent,
} from '../../services/api'; } from '../../services/api';
import { optimizerApi } from '../../api/optimizer.api'; import { optimizerApi } from '../../api/optimizer.api';
import { useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, TaskIcon, CheckCircleIcon } from '../../icons'; import { FileIcon, TaskIcon, CheckCircleIcon } from '../../icons';
import { createContentPageConfig } from '../../config/pages/content.config'; import { createContentPageConfig } from '../../config/pages/content.config';
@@ -24,7 +24,7 @@ import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal'; import { useProgressModal } from '../../hooks/useProgressModal';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { WorkflowInsight } from '../../components/common/WorkflowInsights'; import { PencilSquareIcon } from '@heroicons/react/24/outline';
export default function Content() { export default function Content() {
const toast = useToast(); const toast = useToast();
@@ -55,59 +55,6 @@ export default function Content() {
const progressModal = useProgressModal(); const progressModal = useProgressModal();
const hasReloadedRef = useRef(false); const hasReloadedRef = useRef(false);
// Calculate workflow insights
const workflowInsights: WorkflowInsight[] = useMemo(() => {
const insights: WorkflowInsight[] = [];
const draftCount = content.filter(c => c.status === 'draft').length;
const reviewCount = content.filter(c => c.status === 'review').length;
const publishedCount = content.filter(c => c.status === 'published').length;
const publishingRate = totalCount > 0 ? Math.round((publishedCount / totalCount) * 100) : 0;
if (totalCount === 0) {
insights.push({
type: 'info',
message: 'No content yet - Generate content from tasks to build your content library',
});
return insights;
}
// Draft vs Review status
if (draftCount > reviewCount * 3 && draftCount >= 5) {
insights.push({
type: 'warning',
message: `${draftCount} drafts waiting for review - Move content to review stage for quality assurance`,
});
} else if (draftCount > 0) {
insights.push({
type: 'info',
message: `${draftCount} drafts in progress - Review and refine before moving to publish stage`,
});
}
// Review queue status
if (reviewCount > 0) {
insights.push({
type: 'action',
message: `${reviewCount} pieces awaiting final review - Approve and publish when ready`,
});
}
// Publishing readiness
if (publishingRate >= 60 && publishedCount >= 10) {
insights.push({
type: 'success',
message: `Strong publishing rate (${publishingRate}%) - ${publishedCount} articles ready for WordPress sync`,
});
} else if (publishedCount > 0) {
insights.push({
type: 'success',
message: `${publishedCount} articles published (${publishingRate}%) - Continue moving content through the pipeline`,
});
}
return insights;
}, [content, totalCount]);
// Load content - wrapped in useCallback // Load content - wrapped in useCallback
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -281,10 +228,20 @@ export default function Content() {
<> <>
<PageHeader <PageHeader
title="Drafts" title="Drafts"
description="AI-generated content ready for review. Add images, edit, and publish when ready." description="Manage content drafts"
badge={{ icon: <FileIcon />, color: 'purple' }} badge={{ icon: <PencilSquareIcon />, color: 'orange' }}
breadcrumb="Writer / Drafts" breadcrumb="Writer"
workflowInsights={workflowInsights} actions={
<Link
to="/writer/images"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
Generate Images
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -4,6 +4,7 @@
*/ */
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContentImages, fetchContentImages,
@@ -19,6 +20,7 @@ import {
} from '../../services/api'; } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon } from '../../icons'; import { FileIcon, DownloadIcon } from '../../icons';
import { PhotoIcon } from '@heroicons/react/24/outline';
import { createImagesPageConfig } from '../../config/pages/images.config'; import { createImagesPageConfig } from '../../config/pages/images.config';
import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal'; import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal';
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal'; import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
@@ -450,8 +452,20 @@ export default function Images() {
<> <>
<PageHeader <PageHeader
title="Images" title="Images"
badge={{ icon: <FileIcon />, color: 'orange' }} description="Generate and manage content images"
breadcrumb="Writer / Images" badge={{ icon: <PhotoIcon />, color: 'pink' }}
breadcrumb="Writer"
actions={
<Link
to="/writer/review"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
Review Content
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -4,6 +4,7 @@
*/ */
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
@@ -15,9 +16,9 @@ import {
deleteContent, deleteContent,
bulkDeleteContent, bulkDeleteContent,
} from '../../services/api'; } from '../../services/api';
import { useNavigate } from 'react-router-dom';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, CheckCircleIcon } from '../../icons'; import { FileIcon, CheckCircleIcon } from '../../icons';
import { RocketLaunchIcon } from '@heroicons/react/24/outline';
import { createPublishedPageConfig } from '../../config/pages/published.config'; import { createPublishedPageConfig } from '../../config/pages/published.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
@@ -308,8 +309,20 @@ export default function Published() {
<> <>
<PageHeader <PageHeader
title="Published" title="Published"
badge={{ icon: <CheckCircleIcon />, color: 'green' }} description="Published content and WordPress sync status"
breadcrumb="Writer / Published" badge={{ icon: <RocketLaunchIcon />, color: 'green' }}
breadcrumb="Writer"
actions={
<Link
to="/planner/keywords"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
Create More Content
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -4,6 +4,7 @@
*/ */
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
@@ -12,9 +13,9 @@ import {
ContentFilters, ContentFilters,
fetchAPI, fetchAPI,
} from '../../services/api'; } from '../../services/api';
import { useNavigate } from 'react-router-dom';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { CheckCircleIcon } from '../../icons'; import { CheckCircleIcon } from '../../icons';
import { ClipboardDocumentCheckIcon } from '@heroicons/react/24/outline';
import { createReviewPageConfig } from '../../config/pages/review.config'; import { createReviewPageConfig } from '../../config/pages/review.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
@@ -346,9 +347,21 @@ export default function Review() {
return ( return (
<> <>
<PageHeader <PageHeader
title="Review Queue" title="Review"
badge={{ icon: <CheckCircleIcon />, color: 'blue' }} description="Review and approve content before publishing"
breadcrumb="Writer / Review" badge={{ icon: <ClipboardDocumentCheckIcon />, color: 'emerald' }}
breadcrumb="Writer"
actions={
<Link
to="/writer/published"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
View Published
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -3,7 +3,8 @@
* Consistent with Keywords page layout, structure and design * Consistent with Keywords page layout, structure and design
*/ */
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchTasks, fetchTasks,
@@ -30,7 +31,7 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { WorkflowInsight } from '../../components/common/WorkflowInsights'; import { DocumentTextIcon } from '@heroicons/react/24/outline';
export default function Tasks() { export default function Tasks() {
const toast = useToast(); const toast = useToast();
@@ -79,63 +80,6 @@ export default function Tasks() {
// Progress modal for AI functions // Progress modal for AI functions
const progressModal = useProgressModal(); const progressModal = useProgressModal();
// Calculate workflow insights
const workflowInsights: WorkflowInsight[] = useMemo(() => {
const insights: WorkflowInsight[] = [];
const queuedCount = tasks.filter(t => t.status === 'queued').length;
const processingCount = tasks.filter(t => t.status === 'in_progress').length;
const completedCount = tasks.filter(t => t.status === 'completed').length;
const failedCount = tasks.filter(t => t.status === 'failed').length;
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
if (totalCount === 0) {
insights.push({
type: 'info',
message: 'No tasks yet - Queue ideas from Planner to start generating content automatically',
});
return insights;
}
// Queue status
if (queuedCount > 10) {
insights.push({
type: 'warning',
message: `Large queue detected (${queuedCount} tasks) - Content generation may take time, consider prioritizing`,
});
} else if (queuedCount > 0) {
insights.push({
type: 'info',
message: `${queuedCount} tasks in queue - Content generation pipeline is active`,
});
}
// Processing status
if (processingCount > 0) {
insights.push({
type: 'action',
message: `${processingCount} tasks actively generating content - Check back soon for completed drafts`,
});
}
// Failed tasks
if (failedCount > 0) {
insights.push({
type: 'warning',
message: `${failedCount} tasks failed - Review errors and retry or adjust task parameters`,
});
}
// Completion success
if (completionRate >= 70 && completedCount >= 5) {
insights.push({
type: 'success',
message: `High completion rate (${completionRate}%) - ${completedCount} pieces of content ready for review`,
});
}
return insights;
}, [tasks, totalCount]);
// AI Function Logs state // AI Function Logs state
@@ -424,11 +368,21 @@ export default function Tasks() {
return ( return (
<> <>
<PageHeader <PageHeader
title="Content Queue" title="Queue"
description="Manage content tasks waiting for AI generation. Queue ideas here to create articles automatically." description="Content writing queue"
badge={{ icon: <TaskIcon />, color: 'indigo' }} badge={{ icon: <DocumentTextIcon />, color: 'blue' }}
breadcrumb="Writer / Queue" breadcrumb="Writer"
workflowInsights={workflowInsights} actions={
<Link
to="/writer/content"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
>
View Drafts
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
}
/> />
<TablePageTemplate <TablePageTemplate
columns={pageConfig.columns} columns={pageConfig.columns}

View File

@@ -384,7 +384,7 @@ export default function PlansAndBillingPage() {
<div className="mb-4 p-4 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200"> <div className="mb-4 p-4 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
No active plan. Choose a plan below to activate your account. No active plan. Choose a plan below to activate your account.
</div> </div>
)}} )}
{hasPendingManualPayment && ( {hasPendingManualPayment && (
<div className="mb-4 p-4 rounded-lg border border-[var(--color-info-200)] bg-[var(--color-info-50)] text-[var(--color-info-800)] dark:border-[var(--color-info-800)] dark:bg-[var(--color-info-900)]/20 dark:text-[var(--color-info-100)]"> <div className="mb-4 p-4 rounded-lg border border-[var(--color-info-200)] bg-[var(--color-info-50)] text-[var(--color-info-800)] dark:border-[var(--color-info-800)] dark:bg-[var(--color-info-900)]/20 dark:text-[var(--color-info-100)]">
We received your manual payment. Its pending admin approval; activation will complete once approved. We received your manual payment. Its pending admin approval; activation will complete once approved.