ADD new frontend
This commit is contained in:
441
frontend/src/components/layout/PageHeader/PageHeader.tsx
Normal file
441
frontend/src/components/layout/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Breadcrumbs } from '../Breadcrumbs';
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Calendar,
|
||||
Clock,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ActionButton {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
};
|
||||
tooltip?: string;
|
||||
external?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface MetadataItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
tooltip?: string;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
export interface PageHeaderProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Page title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Optional subtitle
|
||||
*/
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Page description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Action buttons
|
||||
*/
|
||||
actions?: ActionButton[];
|
||||
/**
|
||||
* Metadata items (last updated, created by, etc.)
|
||||
*/
|
||||
metadata?: MetadataItem[];
|
||||
/**
|
||||
* Show back button
|
||||
*/
|
||||
showBackButton?: boolean;
|
||||
/**
|
||||
* Custom back button handler
|
||||
*/
|
||||
onBack?: () => void;
|
||||
/**
|
||||
* Show breadcrumbs
|
||||
*/
|
||||
showBreadcrumbs?: boolean;
|
||||
/**
|
||||
* Custom breadcrumb props
|
||||
*/
|
||||
breadcrumbProps?: any;
|
||||
/**
|
||||
* Status badge
|
||||
*/
|
||||
status?: {
|
||||
text: string;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
};
|
||||
/**
|
||||
* Page icon
|
||||
*/
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Loading state
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Error state
|
||||
*/
|
||||
error?: string;
|
||||
/**
|
||||
* Custom refresh handler
|
||||
*/
|
||||
onRefresh?: () => void;
|
||||
/**
|
||||
* Show refresh button
|
||||
*/
|
||||
showRefreshButton?: boolean;
|
||||
/**
|
||||
* Sticky header
|
||||
*/
|
||||
sticky?: boolean;
|
||||
/**
|
||||
* Compact mode (smaller padding and text)
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Center align content
|
||||
*/
|
||||
centered?: boolean;
|
||||
/**
|
||||
* Custom content in header
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PageHeaderRef {
|
||||
scrollIntoView: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageHeader - Page-level header with title, actions, and metadata
|
||||
*
|
||||
* Features:
|
||||
* - Page title with optional subtitle and description
|
||||
* - Action buttons area with various button types and states
|
||||
* - Metadata display (last updated, created by, etc.) with icons
|
||||
* - Optional back button with navigation handling
|
||||
* - Integrated breadcrumb navigation
|
||||
* - Status badges and page icons
|
||||
* - Loading and error states
|
||||
* - Responsive layout with mobile adaptations
|
||||
* - Sticky positioning support
|
||||
* - Keyboard shortcuts and accessibility
|
||||
*/
|
||||
export const PageHeader = forwardRef<PageHeaderRef, PageHeaderProps>(({
|
||||
className,
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
actions = [],
|
||||
metadata = [],
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
showBreadcrumbs = true,
|
||||
breadcrumbProps,
|
||||
status,
|
||||
icon: PageIcon,
|
||||
loading = false,
|
||||
error,
|
||||
onRefresh,
|
||||
showRefreshButton = false,
|
||||
sticky = false,
|
||||
compact = false,
|
||||
centered = false,
|
||||
children,
|
||||
}, ref) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const headerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle back navigation
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = () => {
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll into view
|
||||
const scrollIntoView = React.useCallback(() => {
|
||||
headerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, []);
|
||||
|
||||
// Expose ref methods
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollIntoView,
|
||||
refresh: handleRefresh,
|
||||
}), [scrollIntoView, handleRefresh]);
|
||||
|
||||
// Render action button
|
||||
const renderAction = (action: ActionButton) => {
|
||||
const ActionIcon = action.icon;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
{ActionIcon && <ActionIcon className="w-4 h-4" />}
|
||||
<span className={clsx(
|
||||
compact ? 'text-sm' : 'text-sm',
|
||||
ActionIcon && 'ml-2'
|
||||
)}>
|
||||
{action.label}
|
||||
</span>
|
||||
{action.badge && (
|
||||
<Badge
|
||||
variant={action.badge.variant || 'default'}
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
>
|
||||
{action.badge.text}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (action.href) {
|
||||
return (
|
||||
<a
|
||||
key={action.id}
|
||||
href={action.href}
|
||||
target={action.external ? '_blank' : undefined}
|
||||
rel={action.external ? 'noopener noreferrer' : undefined}
|
||||
className={clsx(
|
||||
'inline-flex items-center px-4 py-2 rounded-lg font-medium transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
action.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
title={action.tooltip}
|
||||
>
|
||||
{buttonContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant || 'outline'}
|
||||
size={action.size || 'md'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || loading}
|
||||
loading={action.loading}
|
||||
title={action.tooltip}
|
||||
className={compact ? 'px-3 py-1.5' : undefined}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Render metadata item
|
||||
const renderMetadata = (item: MetadataItem) => {
|
||||
const ItemIcon = item.icon;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (item.copyable && typeof item.value === 'string') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.value);
|
||||
// TODO: Show toast notification
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
|
||||
title={item.tooltip}
|
||||
>
|
||||
{ItemIcon && <ItemIcon className="w-4 h-4 text-[var(--text-tertiary)]" />}
|
||||
<span className="font-medium">{item.label}:</span>
|
||||
{item.copyable && typeof item.value === 'string' ? (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="hover:text-[var(--text-primary)] transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
) : (
|
||||
<span>{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'bg-[var(--bg-primary)] border-b border-[var(--border-primary)]',
|
||||
sticky && 'sticky top-[var(--header-height)] z-[var(--z-sticky)]',
|
||||
compact ? 'py-4' : 'py-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'max-w-full px-4 lg:px-6',
|
||||
centered && 'mx-auto max-w-4xl'
|
||||
)}>
|
||||
{/* Breadcrumbs */}
|
||||
{showBreadcrumbs && (
|
||||
<div className="mb-4">
|
||||
<Breadcrumbs {...breadcrumbProps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main header content */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
{/* Left section - Title and metadata */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className="p-2 -ml-2"
|
||||
aria-label="Volver"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{PageIcon && (
|
||||
<PageIcon className={clsx(
|
||||
'text-[var(--text-tertiary)]',
|
||||
compact ? 'w-5 h-5' : 'w-6 h-6'
|
||||
)} />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className={clsx(
|
||||
'font-bold text-[var(--text-primary)] truncate',
|
||||
compact ? 'text-xl' : 'text-2xl lg:text-3xl'
|
||||
)}>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{status && (
|
||||
<Badge
|
||||
variant={status.variant || 'default'}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
>
|
||||
{status.text}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<RefreshCw className="w-4 h-4 text-[var(--text-tertiary)] animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtitle && (
|
||||
<p className={clsx(
|
||||
'text-[var(--text-secondary)] mt-1',
|
||||
compact ? 'text-sm' : 'text-lg'
|
||||
)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRefreshButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-2"
|
||||
aria-label="Actualizar"
|
||||
>
|
||||
<RefreshCw className={clsx(
|
||||
'w-4 h-4',
|
||||
loading && 'animate-spin'
|
||||
)} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className={clsx(
|
||||
'text-[var(--text-secondary)] mb-3',
|
||||
compact ? 'text-sm' : 'text-base'
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-3 p-3 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
|
||||
<p className="text-sm text-[var(--color-error)]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{metadata.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{metadata.map(renderMetadata)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom children */}
|
||||
{children && (
|
||||
<div className="mt-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section - Actions */}
|
||||
{actions.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap lg:flex-nowrap">
|
||||
{actions.map(renderAction)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PageHeader.displayName = 'PageHeader';
|
||||
2
frontend/src/components/layout/PageHeader/index.ts
Normal file
2
frontend/src/components/layout/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { PageHeaderProps, PageHeaderRef, ActionButton, MetadataItem } from './PageHeader';
|
||||
Reference in New Issue
Block a user