header rekated fixes
This commit is contained in:
179
docs/30-FRONTEND/PAGE-REQUIREMENTS.md
Normal file
179
docs/30-FRONTEND/PAGE-REQUIREMENTS.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Page Requirements - Site & Sector Selectors
|
||||||
|
|
||||||
|
This document outlines all pages in the application and their requirements for site/sector selectors.
|
||||||
|
|
||||||
|
## Legend
|
||||||
|
- **Site Selector**: Whether the page needs a site selector dropdown
|
||||||
|
- **Sector Selector**: Whether the page needs a sector selector dropdown
|
||||||
|
- **Implementation**: How the selectors should behave on this page
|
||||||
|
- **Next Action**: Recommended workflow guidance for Planner/Writer pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner Module (Content Planning)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Keywords | `/planner/keywords` | ✅ Required | ✅ Required | Filter keywords by site/sector | `{count} selected → Auto-Cluster` OR `{clustered} clustered → Generate Ideas` |
|
||||||
|
| Clusters | `/planner/clusters` | ✅ Required | ✅ Required | Filter clusters by site/sector | `{count} selected → Expand Clusters` OR `{ready} ready → Generate Ideas` |
|
||||||
|
| Cluster Detail | `/planner/clusters/:id` | ✅ Read-only | ✅ Read-only | Display only (inherited from cluster) | `Back to Clusters` OR `Generate Ideas from Cluster` |
|
||||||
|
| Ideas | `/planner/ideas` | ✅ Required | ✅ Required | Filter ideas by site/sector | `{count} selected → Create Tasks` OR `{approved} approved → Create Tasks` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writer Module (Content Creation)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Tasks | `/writer/tasks` | ✅ Required | ✅ Required | Filter tasks by site/sector | `{count} selected → Generate Content` OR `{ready} ready → Generate Content` |
|
||||||
|
| Content | `/writer/content` | ✅ Required | ✅ Required | Filter content by site/sector | `{count} selected → Generate Images` OR `{draft} drafts → Add Images` |
|
||||||
|
| Images | `/writer/images` | ✅ Required | ✅ Required | Filter images by site/sector | `{count} selected → Submit for Review` OR `{ready} ready → Submit for Review` |
|
||||||
|
| Review | `/writer/review` | ✅ Required | ✅ Required | Filter review items by site/sector | `{count} selected → Publish Selected` OR `{approved} approved → Publish All` |
|
||||||
|
| Published | `/writer/published` | ✅ Required | ✅ Required | Filter published items by site/sector | `{count} selected → Sync to WordPress` OR `View All Sites` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linker Module (Internal Linking)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Content List | `/linker/content` | ✅ Required | ✅ Optional | Filter content by site/sector | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimizer Module (Content Optimization)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Content Selector | `/optimizer/content` | ✅ Required | ✅ Optional | Filter content for optimization | N/A |
|
||||||
|
| Analysis Preview | `/optimizer/analyze/:id` | ✅ Read-only | ❌ Not needed | Display only (inherited from content) | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thinker Module (AI Configuration) - Admin Only
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Prompts | `/thinker/prompts` | ❌ Global | ❌ Global | System-wide prompts | N/A |
|
||||||
|
| Author Profiles | `/thinker/author-profiles` | ❌ Global | ❌ Global | System-wide profiles | N/A |
|
||||||
|
| Strategies | `/thinker/strategies` | ❌ Global | ❌ Global | System-wide strategies | N/A |
|
||||||
|
| Image Testing | `/thinker/image-testing` | ❌ Global | ❌ Global | Testing interface | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sites Module (Site Management)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Site List | `/sites` | ❌ Shows all | ❌ Not applicable | Lists all sites | N/A |
|
||||||
|
| Site Dashboard | `/sites/:id` | ✅ Read-only | ❌ Not needed | Inherited from route param | N/A |
|
||||||
|
| Site Content | `/sites/:id/content` | ✅ Read-only | ✅ Optional | Filter by sector within site | N/A |
|
||||||
|
| Page Manager | `/sites/:id/pages` | ✅ Read-only | ❌ Not needed | Inherited from route param | N/A |
|
||||||
|
| Site Settings | `/sites/:id/settings` | ✅ Read-only | ❌ Not needed | Inherited from route param | N/A |
|
||||||
|
| Sync Dashboard | `/sites/:id/sync` | ✅ Read-only | ❌ Not needed | Inherited from route param | N/A |
|
||||||
|
| Deployment Panel | `/sites/:id/deploy` | ✅ Read-only | ❌ Not needed | Inherited from route param | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account & Billing
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Account Settings | `/account/settings` | ❌ Not needed | ❌ Not needed | Account-level settings | N/A |
|
||||||
|
| Plans & Billing | `/account/plans` | ❌ Not needed | ❌ Not needed | Account-level billing | N/A |
|
||||||
|
| Purchase Credits | `/account/purchase-credits` | ❌ Not needed | ❌ Not needed | Account-level purchase | N/A |
|
||||||
|
| Usage Analytics | `/account/usage` | ✅ Optional | ❌ Not needed | Filter usage by site | N/A |
|
||||||
|
| Content Settings | `/account/content-settings` | ❌ Not needed | ❌ Not needed | Account-level settings | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings (Admin)
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| General Settings | `/settings` | ❌ Not needed | ❌ Not needed | System-wide settings | N/A |
|
||||||
|
| Users | `/settings/users` | ❌ Not needed | ❌ Not needed | User management | N/A |
|
||||||
|
| Subscriptions | `/settings/subscriptions` | ❌ Not needed | ❌ Not needed | Subscription management | N/A |
|
||||||
|
| System | `/settings/system` | ❌ Not needed | ❌ Not needed | System configuration | N/A |
|
||||||
|
| Account | `/settings/account` | ❌ Not needed | ❌ Not needed | Account settings | N/A |
|
||||||
|
| AI Settings | `/settings/ai` | ❌ Not needed | ❌ Not needed | AI model configuration | N/A |
|
||||||
|
| Plans | `/settings/plans` | ❌ Not needed | ❌ Not needed | Plan management | N/A |
|
||||||
|
| Industries | `/settings/industries` | ❌ Not needed | ❌ Not needed | Industry reference data | N/A |
|
||||||
|
| Integration | `/settings/integration` | ❌ Not needed | ❌ Not needed | API integrations | N/A |
|
||||||
|
| Publishing | `/settings/publishing` | ❌ Not needed | ❌ Not needed | Publishing defaults | N/A |
|
||||||
|
| Sites | `/settings/sites` | ❌ Not needed | ❌ Not needed | Site configuration | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Data
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Seed Keywords | `/reference/seed-keywords` | ✅ Optional | ✅ Optional | Filter reference keywords | N/A |
|
||||||
|
| Industries | `/reference/industries` | ❌ Not needed | ❌ Not needed | Global reference data | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Add Keywords | `/setup/add-keywords` | ✅ Required | ✅ Required | Target site/sector for import | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other Pages
|
||||||
|
|
||||||
|
| Page | Route | Site Selector | Sector Selector | Implementation | Recommended Next Action |
|
||||||
|
|------|-------|---------------|-----------------|----------------|-------------------------|
|
||||||
|
| Home Dashboard | `/` | ✅ Optional | ❌ Not needed | Overview all sites or filter | N/A |
|
||||||
|
| Help | `/help` | ❌ Not needed | ❌ Not needed | Documentation | N/A |
|
||||||
|
| Components | `/components` | ❌ Not needed | ❌ Not needed | Design system showcase | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Site Selector Behavior
|
||||||
|
- **Required**: User must select a site before content is displayed
|
||||||
|
- **Optional**: Shows all sites by default, can filter to specific site
|
||||||
|
- **Read-only**: Shows the current site but cannot be changed (inherited from route/context)
|
||||||
|
- **Not needed**: Page operates at account/system level
|
||||||
|
|
||||||
|
### Sector Selector Behavior
|
||||||
|
- **Required**: User must select both site and sector
|
||||||
|
- **Optional**: Shows all sectors by default within selected site
|
||||||
|
- **Read-only**: Shows current sector but cannot be changed
|
||||||
|
- **Not needed**: Page doesn't operate at sector level
|
||||||
|
|
||||||
|
### Next Action Patterns (Planner/Writer)
|
||||||
|
The next action button should follow this pattern:
|
||||||
|
1. If items are selected → Action on selected items
|
||||||
|
2. If no selection but ready items exist → Workflow progression action
|
||||||
|
3. If nothing actionable → Hide or disable
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```tsx
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Process Selected',
|
||||||
|
message: `${selectedIds.length} items selected`,
|
||||||
|
onClick: handleBulkAction,
|
||||||
|
} : workflowStats.ready > 0 ? {
|
||||||
|
label: 'Continue to Next Step',
|
||||||
|
href: '/next/page',
|
||||||
|
message: `${workflowStats.ready} items ready`,
|
||||||
|
} : undefined}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Pipeline (Planner → Writer)
|
||||||
|
|
||||||
|
```
|
||||||
|
Keywords → Clusters → Ideas → Tasks → Content → Images → Review → Published
|
||||||
|
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
|
||||||
|
Cluster Expand Create Generate Generate Submit Publish Sync to
|
||||||
|
Keywords Ideas Tasks Content Images Review Content WordPress
|
||||||
|
```
|
||||||
|
|
||||||
|
Each page's "next action" guides users through this pipeline.
|
||||||
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/auth/ProtectedRoute";
|
|||||||
import AdminRoute from "./components/auth/AdminRoute";
|
import AdminRoute from "./components/auth/AdminRoute";
|
||||||
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
|
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
|
||||||
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
|
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
|
||||||
|
import { PageProvider } from "./context/PageContext";
|
||||||
import { useAuthStore } from "./store/authStore";
|
import { useAuthStore } from "./store/authStore";
|
||||||
import { useModuleStore } from "./store/moduleStore";
|
import { useModuleStore } from "./store/moduleStore";
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export default function App() {
|
|||||||
}, [loadModuleSettings]);
|
}, [loadModuleSettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PageProvider>
|
||||||
<GlobalErrorDisplay />
|
<GlobalErrorDisplay />
|
||||||
<LoadingStateMonitor />
|
<LoadingStateMonitor />
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
@@ -265,6 +266,6 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</>
|
</PageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Standardized Page Header Component
|
* Standardized Page Header Component
|
||||||
* Simplified version - Site/sector selector moved to AppHeader
|
* Simplified version - Title shown in AppHeader
|
||||||
* Just shows: breadcrumb (inline), page title with badge, description
|
* Shows: page title with parent badge, description
|
||||||
*/
|
*/
|
||||||
import React, { ReactNode, useEffect, useRef } from 'react';
|
import React, { ReactNode, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
import { trackLoading } from './LoadingStateMonitor';
|
import { trackLoading } from './LoadingStateMonitor';
|
||||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||||
|
import { usePageContext } from '../../context/PageContext';
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
breadcrumb?: string;
|
parent?: string; // Parent module name (e.g., "Planner", "Writer")
|
||||||
|
breadcrumb?: string; // Deprecated - use parent instead
|
||||||
lastUpdated?: Date;
|
lastUpdated?: Date;
|
||||||
showRefresh?: boolean;
|
showRefresh?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
badge?: {
|
badge?: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo';
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||||
};
|
};
|
||||||
hideSiteSector?: boolean;
|
hideSiteSector?: boolean;
|
||||||
navigation?: ReactNode; // Kept for backwards compat but not rendered
|
navigation?: ReactNode; // Kept for backwards compat but not rendered
|
||||||
@@ -31,7 +33,8 @@ interface PageHeaderProps {
|
|||||||
export default function PageHeader({
|
export default function PageHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
breadcrumb,
|
parent,
|
||||||
|
breadcrumb, // Deprecated alias for parent
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
showRefresh = false,
|
showRefresh = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -43,9 +46,20 @@ export default function PageHeader({
|
|||||||
const { activeSite } = useSiteStore();
|
const { activeSite } = useSiteStore();
|
||||||
const { loadSectorsForSite } = useSectorStore();
|
const { loadSectorsForSite } = useSectorStore();
|
||||||
const { addError } = useErrorHandler('PageHeader');
|
const { addError } = useErrorHandler('PageHeader');
|
||||||
|
const { setPageInfo } = usePageContext();
|
||||||
const lastSiteId = useRef<number | null>(null);
|
const lastSiteId = useRef<number | null>(null);
|
||||||
const isLoadingSector = useRef(false);
|
const isLoadingSector = useRef(false);
|
||||||
|
|
||||||
|
// Resolve parent from either prop
|
||||||
|
const parentModule = parent || breadcrumb;
|
||||||
|
|
||||||
|
// Update page context with title and badge info for AppHeader
|
||||||
|
const pageInfoKey = useMemo(() => `${title}|${parentModule}`, [title, parentModule]);
|
||||||
|
useEffect(() => {
|
||||||
|
setPageInfo({ title, parent: parentModule, badge });
|
||||||
|
return () => setPageInfo(null);
|
||||||
|
}, [pageInfoKey, badge?.color]);
|
||||||
|
|
||||||
// Load sectors when active site changes
|
// Load sectors when active site changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hideSiteSector) return;
|
if (hideSiteSector) return;
|
||||||
@@ -83,57 +97,28 @@ export default function PageHeader({
|
|||||||
}, [activeSite?.id, activeSite?.is_active, hideSiteSector, loadSectorsForSite, addError]);
|
}, [activeSite?.id, activeSite?.is_active, hideSiteSector, loadSectorsForSite, addError]);
|
||||||
|
|
||||||
const badgeColors = {
|
const badgeColors = {
|
||||||
blue: 'bg-blue-600 dark:bg-blue-500',
|
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
|
||||||
green: 'bg-green-600 dark:bg-green-500',
|
green: { bg: 'bg-green-600 dark:bg-green-500', light: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300' },
|
||||||
purple: 'bg-purple-600 dark:bg-purple-500',
|
purple: { bg: 'bg-purple-600 dark:bg-purple-500', light: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300' },
|
||||||
orange: 'bg-orange-600 dark:bg-orange-500',
|
orange: { bg: 'bg-orange-600 dark:bg-orange-500', light: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' },
|
||||||
red: 'bg-red-600 dark:bg-red-500',
|
red: { bg: 'bg-red-600 dark:bg-red-500', light: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' },
|
||||||
indigo: 'bg-indigo-600 dark:bg-indigo-500',
|
indigo: { bg: 'bg-indigo-600 dark:bg-indigo-500', light: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300' },
|
||||||
|
yellow: { bg: 'bg-yellow-600 dark:bg-yellow-500', light: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' },
|
||||||
|
pink: { bg: 'bg-pink-600 dark:bg-pink-500', light: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300' },
|
||||||
|
emerald: { bg: 'bg-emerald-600 dark:bg-emerald-500', light: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' },
|
||||||
|
cyan: { bg: 'bg-cyan-600 dark:bg-cyan-500', light: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300' },
|
||||||
|
amber: { bg: 'bg-amber-600 dark:bg-amber-500', light: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300' },
|
||||||
|
teal: { bg: 'bg-teal-600 dark:bg-teal-500', light: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-300' },
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center justify-between gap-4 ${className}`}>
|
<div className={`${className}`}>
|
||||||
{/* Left: Breadcrumb + Badge + Title */}
|
{/* Title now shown in AppHeader - this component only triggers the context update */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{/* Show description if provided - can be used for additional context */}
|
||||||
{breadcrumb && (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{badge && (
|
|
||||||
<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
|
|
||||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-4' })
|
|
||||||
: badge.icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<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 truncate">{description}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{description}</p>
|
||||||
)}
|
)}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Page Context - Shares current page info with header
|
* Page Context - Shares current page info with header
|
||||||
* Allows pages to set title, breadcrumb, badge for display in AppHeader
|
* Allows pages to set title, parent module, badge for display in AppHeader
|
||||||
*/
|
*/
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
interface PageInfo {
|
interface PageInfo {
|
||||||
title: string;
|
title: string;
|
||||||
breadcrumb?: string;
|
parent?: string; // Parent module name (e.g., "Planner", "Writer")
|
||||||
badge?: {
|
badge?: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo';
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,5 +44,5 @@ export function usePage(info: PageInfo) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPageInfo(info);
|
setPageInfo(info);
|
||||||
return () => setPageInfo(null);
|
return () => setPageInfo(null);
|
||||||
}, [info.title, info.breadcrumb]);
|
}, [info.title, info.parent]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import { useEffect, 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 { usePageContext } from "../context/PageContext";
|
||||||
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 SearchModal from "../components/common/SearchModal";
|
||||||
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
|
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Badge color mappings for light versions
|
||||||
|
const badgeColors: Record<string, { bg: string; light: string }> = {
|
||||||
|
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
|
||||||
|
green: { bg: 'bg-green-600 dark:bg-green-500', light: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300' },
|
||||||
|
purple: { bg: 'bg-purple-600 dark:bg-purple-500', light: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300' },
|
||||||
|
orange: { bg: 'bg-orange-600 dark:bg-orange-500', light: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' },
|
||||||
|
red: { bg: 'bg-red-600 dark:bg-red-500', light: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' },
|
||||||
|
indigo: { bg: 'bg-indigo-600 dark:bg-indigo-500', light: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300' },
|
||||||
|
yellow: { bg: 'bg-yellow-600 dark:bg-yellow-500', light: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' },
|
||||||
|
pink: { bg: 'bg-pink-600 dark:bg-pink-500', light: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300' },
|
||||||
|
emerald: { bg: 'bg-emerald-600 dark:bg-emerald-500', light: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' },
|
||||||
|
cyan: { bg: 'bg-cyan-600 dark:bg-cyan-500', light: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300' },
|
||||||
|
amber: { bg: 'bg-amber-600 dark:bg-amber-500', light: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300' },
|
||||||
|
teal: { bg: 'bg-teal-600 dark:bg-teal-500', light: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-300' },
|
||||||
|
};
|
||||||
|
|
||||||
const AppHeader: React.FC = () => {
|
const AppHeader: React.FC = () => {
|
||||||
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const { pageInfo } = usePageContext();
|
||||||
const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (window.innerWidth >= 1024) {
|
|
||||||
toggleSidebar();
|
|
||||||
} else {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleApplicationMenu = () => {
|
const toggleApplicationMenu = () => {
|
||||||
setApplicationMenuOpen(!isApplicationMenuOpen);
|
setApplicationMenuOpen(!isApplicationMenuOpen);
|
||||||
@@ -44,23 +52,6 @@ const AppHeader: React.FC = () => {
|
|||||||
<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
|
|
||||||
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}
|
|
||||||
aria-label="Toggle Sidebar"
|
|
||||||
>
|
|
||||||
{isMobileOpen ? (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Mobile Logo */}
|
{/* Mobile Logo */}
|
||||||
<Link to="/" className="lg:hidden">
|
<Link to="/" className="lg:hidden">
|
||||||
<img className="dark:hidden" src="./images/logo/logo.svg" alt="Logo" />
|
<img className="dark:hidden" src="./images/logo/logo.svg" alt="Logo" />
|
||||||
@@ -77,8 +68,27 @@ const AppHeader: React.FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Page Title with Badge - Desktop */}
|
||||||
|
{pageInfo && (
|
||||||
|
<div className="hidden lg:flex items-center gap-3">
|
||||||
|
{pageInfo.badge && (
|
||||||
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${badgeColors[pageInfo.badge.color]?.bg || 'bg-gray-600'} flex-shrink-0`}>
|
||||||
|
{pageInfo.badge.icon && typeof pageInfo.badge.icon === 'object' && 'type' in pageInfo.badge.icon
|
||||||
|
? React.cloneElement(pageInfo.badge.icon as React.ReactElement, { className: 'text-white size-4' })
|
||||||
|
: pageInfo.badge.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="text-lg font-semibold text-gray-800 dark:text-white">{pageInfo.title}</h1>
|
||||||
|
{pageInfo.parent && pageInfo.badge && (
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${badgeColors[pageInfo.badge.color]?.light || 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{pageInfo.parent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Site and Sector Selector - Desktop */}
|
{/* Site and Sector Selector - Desktop */}
|
||||||
<div className="hidden lg:flex items-center gap-4 ml-4">
|
<div className="hidden lg:flex items-center gap-4 ml-auto">
|
||||||
<SiteAndSectorSelector />
|
<SiteAndSectorSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type MenuSection = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AppSidebar: React.FC = () => {
|
const AppSidebar: React.FC = () => {
|
||||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
const { isExpanded, isMobileOpen, isHovered, setIsHovered, toggleSidebar } = useSidebar();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
const { isModuleEnabled, settings: moduleSettings } = useModuleStore();
|
const { isModuleEnabled, settings: moduleSettings } = useModuleStore();
|
||||||
@@ -452,6 +452,22 @@ const AppSidebar: React.FC = () => {
|
|||||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
|
{/* Collapse/Expand Toggle Button - Attached to border */}
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="hidden lg:flex absolute -right-3 top-20 w-6 h-6 items-center justify-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-full shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors z-50"
|
||||||
|
aria-label={isExpanded ? "Collapse Sidebar" : "Expand Sidebar"}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-300 ${isExpanded || isHovered ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="py-4 flex justify-center items-center">
|
<div className="py-4 flex justify-center items-center">
|
||||||
<Link to="/" className="flex justify-center items-center">
|
<Link to="/" className="flex justify-center items-center">
|
||||||
{isExpanded || isHovered || isMobileOpen ? (
|
{isExpanded || isHovered || isMobileOpen ? (
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -390,20 +389,8 @@ export default function Clusters() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clusters"
|
title="Clusters"
|
||||||
description="Group keywords into topic clusters"
|
|
||||||
badge={{ icon: <GroupIcon />, color: 'purple' }}
|
badge={{ icon: <GroupIcon />, color: 'purple' }}
|
||||||
breadcrumb="Planner"
|
parent="Planner"
|
||||||
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}
|
||||||
@@ -418,6 +405,15 @@ export default function Clusters() {
|
|||||||
volumeMin: volumeMin,
|
volumeMin: volumeMin,
|
||||||
volumeMax: volumeMax,
|
volumeMax: volumeMax,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Generate Ideas',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('generate_ideas', selectedIds),
|
||||||
|
} : clusters.length > 0 ? {
|
||||||
|
label: 'Generate Ideas',
|
||||||
|
href: '/planner/ideas',
|
||||||
|
message: `${clusters.length} clusters`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -301,20 +300,8 @@ export default function Ideas() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Ideas"
|
title="Ideas"
|
||||||
description="Content ideas generated from keywords"
|
|
||||||
badge={{ icon: <LightBulbIcon />, color: 'yellow' }}
|
badge={{ icon: <LightBulbIcon />, color: 'yellow' }}
|
||||||
breadcrumb="Planner"
|
parent="Planner"
|
||||||
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}
|
||||||
@@ -329,6 +316,15 @@ export default function Ideas() {
|
|||||||
content_structure: structureFilter,
|
content_structure: structureFilter,
|
||||||
content_type: typeFilter,
|
content_type: typeFilter,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Queue to Writer',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('queue_to_writer', selectedIds),
|
||||||
|
} : ideas.filter(i => i.status === 'approved').length > 0 ? {
|
||||||
|
label: 'Start Writing',
|
||||||
|
href: '/writer/queue',
|
||||||
|
message: `${ideas.filter(i => i.status === 'approved').length} approved`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -507,20 +506,6 @@ export default function Keywords() {
|
|||||||
};
|
};
|
||||||
}, [keywords, totalCount]);
|
}, [keywords, totalCount]);
|
||||||
|
|
||||||
// Determine next step action
|
|
||||||
const nextStep = useMemo(() => {
|
|
||||||
if (totalCount === 0) {
|
|
||||||
return { label: 'Import Keywords', path: '/add-keywords', disabled: false };
|
|
||||||
}
|
|
||||||
if (workflowStats.unclustered >= 5) {
|
|
||||||
return { label: 'Auto-Cluster', action: 'cluster', disabled: false };
|
|
||||||
}
|
|
||||||
if (workflowStats.clustered > 0) {
|
|
||||||
return { label: 'Generate Ideas', path: '/planner/ideas', disabled: false };
|
|
||||||
}
|
|
||||||
return { label: 'Add More Keywords', path: '/add-keywords', disabled: false };
|
|
||||||
}, [totalCount, workflowStats]);
|
|
||||||
|
|
||||||
// Handle create/edit
|
// Handle create/edit
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -594,37 +579,8 @@ export default function Keywords() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Keywords"
|
title="Keywords"
|
||||||
description="Your target search terms organized for content creation"
|
|
||||||
badge={{ icon: <ListIcon />, color: 'green' }}
|
badge={{ icon: <ListIcon />, color: 'green' }}
|
||||||
breadcrumb="Planner"
|
parent="Planner"
|
||||||
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}
|
||||||
@@ -641,6 +597,19 @@ export default function Keywords() {
|
|||||||
volumeMin: volumeMin,
|
volumeMin: volumeMin,
|
||||||
volumeMax: volumeMax,
|
volumeMax: volumeMax,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Auto-Cluster Selected',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: handleAutoCluster,
|
||||||
|
} : workflowStats.unclustered >= 5 ? {
|
||||||
|
label: 'Auto-Cluster All',
|
||||||
|
message: `${workflowStats.unclustered} unclustered`,
|
||||||
|
onClick: handleAutoCluster,
|
||||||
|
} : workflowStats.clustered > 0 ? {
|
||||||
|
label: 'Generate Ideas',
|
||||||
|
href: '/planner/ideas',
|
||||||
|
message: `${workflowStats.clustered} clustered`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
// Normalize value to string, preserving empty strings
|
// Normalize value to string, preserving empty strings
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
|
|||||||
@@ -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, Link } from 'react-router-dom';
|
import { useNavigate } 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';
|
||||||
@@ -228,20 +228,8 @@ export default function Content() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Drafts"
|
title="Drafts"
|
||||||
description="Manage content drafts"
|
|
||||||
badge={{ icon: <PencilSquareIcon />, color: 'orange' }}
|
badge={{ icon: <PencilSquareIcon />, color: 'orange' }}
|
||||||
breadcrumb="Writer"
|
parent="Writer"
|
||||||
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}
|
||||||
@@ -254,6 +242,15 @@ export default function Content() {
|
|||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
source: sourceFilter,
|
source: sourceFilter,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Generate Images',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleRowAction('generate_images', { id: selectedIds[0] }),
|
||||||
|
} : content.filter(c => c.status === 'draft').length > 0 ? {
|
||||||
|
label: 'Generate Images',
|
||||||
|
href: '/writer/images',
|
||||||
|
message: `${content.filter(c => c.status === 'draft').length} drafts`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key: string, value: any) => {
|
onFilterChange={(key: string, value: any) => {
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
setSearchTerm(value);
|
setSearchTerm(value);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -452,20 +451,8 @@ export default function Images() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Images"
|
title="Images"
|
||||||
description="Generate and manage content images"
|
|
||||||
badge={{ icon: <PhotoIcon />, color: 'pink' }}
|
badge={{ icon: <PhotoIcon />, color: 'pink' }}
|
||||||
breadcrumb="Writer"
|
parent="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}
|
||||||
@@ -477,6 +464,15 @@ export default function Images() {
|
|||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Generate Images',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('generate_images', selectedIds),
|
||||||
|
} : images.filter(i => i.overall_status === 'ready').length > 0 ? {
|
||||||
|
label: 'Review Content',
|
||||||
|
href: '/writer/review',
|
||||||
|
message: `${images.filter(i => i.overall_status === 'ready').length} ready`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContent,
|
fetchContent,
|
||||||
@@ -309,20 +309,8 @@ export default function Published() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Published"
|
title="Published"
|
||||||
description="Published content and WordPress sync status"
|
|
||||||
badge={{ icon: <RocketLaunchIcon />, color: 'green' }}
|
badge={{ icon: <RocketLaunchIcon />, color: 'green' }}
|
||||||
breadcrumb="Writer"
|
parent="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}
|
||||||
@@ -335,6 +323,15 @@ export default function Published() {
|
|||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
publishStatus: publishStatusFilter,
|
publishStatus: publishStatusFilter,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Sync to WordPress',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('publish_to_wordpress', selectedIds),
|
||||||
|
} : {
|
||||||
|
label: 'Create More Content',
|
||||||
|
href: '/planner/keywords',
|
||||||
|
message: `${content.length} published`,
|
||||||
|
}}
|
||||||
onFilterChange={(key: string, value: any) => {
|
onFilterChange={(key: string, value: any) => {
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
setSearchTerm(value);
|
setSearchTerm(value);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContent,
|
fetchContent,
|
||||||
@@ -348,20 +348,8 @@ export default function Review() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Review"
|
title="Review"
|
||||||
description="Review and approve content before publishing"
|
|
||||||
badge={{ icon: <ClipboardDocumentCheckIcon />, color: 'emerald' }}
|
badge={{ icon: <ClipboardDocumentCheckIcon />, color: 'emerald' }}
|
||||||
breadcrumb="Writer"
|
parent="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}
|
||||||
@@ -372,6 +360,15 @@ export default function Review() {
|
|||||||
filterValues={{
|
filterValues={{
|
||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Publish Selected',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('publish', selectedIds),
|
||||||
|
} : content.filter(c => c.status === 'review').length > 0 ? {
|
||||||
|
label: 'View Published',
|
||||||
|
href: '/writer/published',
|
||||||
|
message: `${content.filter(c => c.status === 'review').length} in review`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, 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,
|
||||||
@@ -369,20 +368,8 @@ export default function Tasks() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Queue"
|
title="Queue"
|
||||||
description="Content writing queue"
|
|
||||||
badge={{ icon: <DocumentTextIcon />, color: 'blue' }}
|
badge={{ icon: <DocumentTextIcon />, color: 'blue' }}
|
||||||
breadcrumb="Writer"
|
parent="Writer"
|
||||||
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}
|
||||||
@@ -398,6 +385,15 @@ export default function Tasks() {
|
|||||||
content_type: typeFilter,
|
content_type: typeFilter,
|
||||||
source: sourceFilter,
|
source: sourceFilter,
|
||||||
}}
|
}}
|
||||||
|
nextAction={selectedIds.length > 0 ? {
|
||||||
|
label: 'Generate Content',
|
||||||
|
message: `${selectedIds.length} selected`,
|
||||||
|
onClick: () => handleBulkAction('generate_content', selectedIds),
|
||||||
|
} : tasks.filter(t => t.status === 'queued').length > 0 ? {
|
||||||
|
label: 'View Drafts',
|
||||||
|
href: '/writer/content',
|
||||||
|
message: `${tasks.filter(t => t.status === 'queued').length} queued`,
|
||||||
|
} : undefined}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ interface TablePageTemplateProps {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
variant?: 'primary' | 'success' | 'danger';
|
variant?: 'primary' | 'success' | 'danger';
|
||||||
}>;
|
}>;
|
||||||
|
// Next action button for workflow guidance (shown in action bar)
|
||||||
|
nextAction?: {
|
||||||
|
label: string;
|
||||||
|
message?: string; // Message to show above button (e.g., "5 selected")
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TablePageTemplate({
|
export default function TablePageTemplate({
|
||||||
@@ -178,6 +186,7 @@ export default function TablePageTemplate({
|
|||||||
className = '',
|
className = '',
|
||||||
customActions,
|
customActions,
|
||||||
bulkActions: customBulkActions,
|
bulkActions: customBulkActions,
|
||||||
|
nextAction,
|
||||||
}: TablePageTemplateProps) {
|
}: TablePageTemplateProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||||
@@ -741,6 +750,44 @@ export default function TablePageTemplate({
|
|||||||
{createLabel}
|
{createLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Next Action Button - Workflow Guidance */}
|
||||||
|
{nextAction && (
|
||||||
|
<div className="flex flex-col items-end ml-2">
|
||||||
|
{nextAction.message && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">{nextAction.message}</span>
|
||||||
|
)}
|
||||||
|
{nextAction.href ? (
|
||||||
|
<a
|
||||||
|
href={nextAction.href}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
nextAction.disabled
|
||||||
|
? 'bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-success-500 text-white hover:bg-success-600 dark:bg-success-600 dark:hover:bg-success-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nextAction.label}
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={nextAction.onClick}
|
||||||
|
disabled={nextAction.disabled}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
nextAction.disabled
|
||||||
|
? 'bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-success-500 text-white hover:bg-success-600 dark:bg-success-600 dark:hover:bg-success-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nextAction.label}
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user