page adn app header mods
This commit is contained in:
@@ -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,97 +92,46 @@ 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 */}
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{breadcrumb && (
|
{breadcrumb && (
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<>
|
||||||
{breadcrumb}
|
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">{breadcrumb}</span>
|
||||||
</div>
|
<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">
|
|
||||||
{/* Left side: Title, badge, and site/sector info */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{badge && (
|
{badge && (
|
||||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${badgeColors[badge.color]} flex-shrink-0`}>
|
||||||
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
||||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
|
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-4' })
|
||||||
: badge.icon}
|
: badge.icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
<div className="min-w-0">
|
||||||
</div>
|
<h1 className="text-xl font-semibold text-gray-800 dark:text-white truncate">{title}</h1>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-xl">{description}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{description}</p>
|
||||||
)}
|
)}
|
||||||
{!hideSiteSector && (
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1">
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<>
|
<span className="text-xs text-gray-400 dark:text-gray-500 hidden sm:block">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
Updated {lastUpdated.toLocaleTimeString()}
|
||||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
</span>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right side: Navigation bar stacked above site/sector selector */}
|
|
||||||
<div className="flex flex-col items-end gap-3 flex-shrink-0">
|
|
||||||
{navigation && <div>{navigation}</div>}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{!hideSiteSector && <SiteAndSectorSelector />}
|
|
||||||
{showRefresh && onRefresh && (
|
{showRefresh && onRefresh && (
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
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"
|
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
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
{actions}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
136
frontend/src/components/common/SearchModal.tsx
Normal file
136
frontend/src/components/common/SearchModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/context/PageContext.tsx
Normal file
48
frontend/src/context/PageContext.tsx
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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">
|
<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">
|
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
|
||||||
<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 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">
|
||||||
|
{/* Sidebar Toggle */}
|
||||||
<button
|
<button
|
||||||
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"
|
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}
|
onClick={handleToggle}
|
||||||
aria-label="Toggle Sidebar"
|
aria-label="Toggle Sidebar"
|
||||||
>
|
>
|
||||||
{isMobileOpen ? (
|
{isMobileOpen ? (
|
||||||
<svg
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
width="24"
|
<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" />
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
width="16"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{/* Cross Icon */}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile Logo */}
|
||||||
<Link to="/" className="lg:hidden">
|
<Link to="/" className="lg:hidden">
|
||||||
<img
|
<img className="dark:hidden" src="./images/logo/logo.svg" alt="Logo" />
|
||||||
className="dark:hidden"
|
<img className="hidden dark:block" src="./images/logo/logo-dark.svg" alt="Logo" />
|
||||||
src="./images/logo/logo.svg"
|
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
className="hidden dark:block"
|
|
||||||
src="./images/logo/logo-dark.svg"
|
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleApplicationMenu}
|
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"
|
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
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
width="24"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
{/* Site and Sector Selector - Desktop */}
|
||||||
<form>
|
<div className="hidden lg:flex items-center gap-4 ml-4">
|
||||||
<div className="relative">
|
<SiteAndSectorSelector />
|
||||||
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
|
</div>
|
||||||
<svg
|
</div>
|
||||||
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">
|
{/* Right side actions */}
|
||||||
<span> ⌘ </span>
|
|
||||||
<span> K </span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
isApplicationMenuOpen ? "flex" : "hidden"
|
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`}
|
} 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">
|
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||||
{/* <!-- Header Metrics (conditional) --> */}
|
{/* Header Metrics */}
|
||||||
<HeaderMetrics />
|
<HeaderMetrics />
|
||||||
{/* <!-- Dark Mode Toggler --> */}
|
|
||||||
|
{/* 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 />
|
<ThemeToggleButton />
|
||||||
{/* <!-- Notification Menu Area --> */}
|
|
||||||
|
{/* Notifications */}
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
{/* <!-- Notification Menu Area --> */}
|
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- User Area --> */}
|
|
||||||
|
{/* User Menu */}
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Search Modal */}
|
||||||
|
<SearchModal isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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. It’s pending admin approval; activation will complete once approved.
|
We received your manual payment. It’s pending admin approval; activation will complete once approved.
|
||||||
|
|||||||
Reference in New Issue
Block a user