ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View File

@@ -0,0 +1,337 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { useLocation, Link } from 'react-router-dom';
import { getBreadcrumbs, getRouteByPath } from '../../../router/routes.config';
import {
ChevronRight,
Home,
MoreHorizontal,
ExternalLink
} from 'lucide-react';
export interface BreadcrumbItem {
label: string;
path: string;
icon?: React.ComponentType<{ className?: string }>;
disabled?: boolean;
external?: boolean;
}
export interface BreadcrumbsProps {
className?: string;
/**
* Custom breadcrumb items (overrides auto-generated ones)
*/
items?: BreadcrumbItem[];
/**
* Show home icon on first item
*/
showHome?: boolean;
/**
* Home path (defaults to '/')
*/
homePath?: string;
/**
* Home label
*/
homeLabel?: string;
/**
* Custom separator component
*/
separator?: React.ReactNode;
/**
* Maximum number of items to show before truncating
*/
maxItems?: number;
/**
* Show truncation in the middle (true) or at the beginning (false)
*/
truncateMiddle?: boolean;
/**
* Custom truncation component
*/
truncationComponent?: React.ReactNode;
/**
* Custom link component for external handling
*/
linkComponent?: React.ComponentType<{
to: string;
className?: string;
children: React.ReactNode;
external?: boolean;
}>;
/**
* Hide breadcrumbs for certain paths
*/
hiddenPaths?: string[];
/**
* Custom item renderer
*/
itemRenderer?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
}
export interface BreadcrumbsRef {
scrollToEnd: () => void;
getCurrentPath: () => string;
}
/**
* Breadcrumbs - Breadcrumb navigation component with links and separators
*
* Features:
* - Auto-generate breadcrumbs from current route path
* - Clickable navigation links with proper hover states
* - Custom separators with good visual hierarchy
* - Truncation for long paths with smart middle/start truncation
* - Home icon and custom icons support
* - External link handling with indicators
* - Keyboard navigation support
* - Responsive design with mobile adaptations
* - Custom renderers for advanced use cases
*/
export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
className,
items: customItems,
showHome = true,
homePath = '/',
homeLabel = 'Inicio',
separator,
maxItems = 5,
truncateMiddle = true,
truncationComponent,
linkComponent: CustomLink,
hiddenPaths = [],
itemRenderer,
}, ref) => {
const location = useLocation();
const containerRef = React.useRef<HTMLDivElement>(null);
// Get breadcrumbs from router config or use custom items
const routeBreadcrumbs = getBreadcrumbs(location.pathname);
const generateItems = (): BreadcrumbItem[] => {
if (customItems) {
return customItems;
}
const items: BreadcrumbItem[] = [];
// Add home if enabled
if (showHome && location.pathname !== homePath) {
items.push({
label: homeLabel,
path: homePath,
icon: Home,
});
}
// Add route breadcrumbs
routeBreadcrumbs.forEach(route => {
// Use breadcrumbTitle if available, otherwise use title
const label = route.meta?.breadcrumbTitle || route.title;
items.push({
label,
path: route.path,
disabled: route.path === location.pathname, // Current page is disabled
});
});
return items;
};
const allItems = generateItems();
// Filter out hidden paths
const visibleItems = allItems.filter(item => !hiddenPaths.includes(item.path));
// Handle truncation
const getTruncatedItems = (): BreadcrumbItem[] => {
if (visibleItems.length <= maxItems) {
return visibleItems;
}
if (truncateMiddle && visibleItems.length > 3) {
// Keep first, last, and some middle items
const first = visibleItems[0];
const last = visibleItems[visibleItems.length - 1];
const secondToLast = visibleItems[visibleItems.length - 2];
return [first, { label: '...', path: '', disabled: true }, secondToLast, last];
} else {
// Keep last maxItems
return [
{ label: '...', path: '', disabled: true },
...visibleItems.slice(-(maxItems - 1))
];
}
};
const displayItems = getTruncatedItems();
// Scroll to end
const scrollToEnd = React.useCallback(() => {
if (containerRef.current) {
containerRef.current.scrollTo({
left: containerRef.current.scrollWidth,
behavior: 'smooth'
});
}
}, []);
// Get current path
const getCurrentPath = React.useCallback(() => {
return location.pathname;
}, [location.pathname]);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollToEnd,
getCurrentPath,
}), [scrollToEnd, getCurrentPath]);
// Auto-scroll to end when items change
React.useEffect(() => {
scrollToEnd();
}, [scrollToEnd, displayItems.length]);
// Default separator
const defaultSeparator = (
<ChevronRight className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
);
// Default truncation component
const defaultTruncationComponent = (
<div className="flex items-center gap-1 px-2 py-1 text-[var(--text-tertiary)]">
<MoreHorizontal className="w-4 h-4" />
</div>
);
// Default link component
const DefaultLink: React.FC<{
to: string;
className?: string;
children: React.ReactNode;
external?: boolean;
}> = ({ to, className, children, external }) => {
if (external) {
return (
<a
href={to}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
return (
<Link to={to} className={className}>
{children}
</Link>
);
};
const LinkComponent = CustomLink || DefaultLink;
// Render single item
const renderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
if (itemRenderer) {
return itemRenderer(item, index, isLast);
}
const ItemIcon = item.icon;
// Handle truncation item
if (item.label === '...') {
return (
<li key={`truncation-${index}`} className="flex items-center">
{truncationComponent || defaultTruncationComponent}
</li>
);
}
const content = (
<div className="flex items-center gap-2 min-w-0">
{ItemIcon && (
<ItemIcon className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
)}
<span className={clsx(
'truncate transition-colors duration-200',
isLast
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)]'
)}>
{item.label}
</span>
{item.external && (
<ExternalLink className="w-3 h-3 text-[var(--text-tertiary)] flex-shrink-0" />
)}
</div>
);
return (
<li key={item.path || index} className="flex items-center min-w-0">
{item.disabled || isLast ? (
<span className="flex items-center gap-2 px-2 py-1 min-w-0">
{content}
</span>
) : (
<LinkComponent
to={item.path}
external={item.external}
className={clsx(
'flex items-center gap-2 px-2 py-1 rounded-md min-w-0',
'transition-colors duration-200',
'hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:bg-[var(--bg-secondary)]'
)}
>
{content}
</LinkComponent>
)}
</li>
);
};
// Don't render if no items or path is hidden
if (displayItems.length === 0 || hiddenPaths.includes(location.pathname)) {
return null;
}
return (
<nav
ref={containerRef}
className={clsx(
'flex items-center gap-1 text-sm overflow-x-auto',
'scrollbar-none pb-1', // Hide scrollbar but allow scrolling
className
)}
aria-label="Breadcrumb"
role="navigation"
>
<ol className="flex items-center gap-1 min-w-0 flex-nowrap">
{displayItems.map((item, index) => {
const isLast = index === displayItems.length - 1;
return (
<React.Fragment key={item.path || index}>
{renderItem(item, index, isLast)}
{!isLast && (
<li className="flex items-center flex-shrink-0 mx-1" aria-hidden="true">
{separator || defaultSeparator}
</li>
)}
</React.Fragment>
);
})}
</ol>
</nav>
);
});
Breadcrumbs.displayName = 'Breadcrumbs';

View File

@@ -0,0 +1,2 @@
export { Breadcrumbs } from './Breadcrumbs';
export type { BreadcrumbsProps, BreadcrumbsRef, BreadcrumbItem } from './Breadcrumbs';