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