chore: refactor project structure and clean up unused components
- Updated `TODO.md` to reflect new testing tasks and final structure expectations. - Simplified TypeScript path mappings in `tsconfig.json` for better clarity. - Revised business logic separation rules in `.cursor/rules` to align with new directory structure. - Deleted unused client components and services to streamline the codebase. - Adjusted import paths in scripts to match the new structure.
This commit is contained in:
44
src/components/ui/Badge.tsx
Normal file
44
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center font-mono font-medium transition-all duration-200';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)]',
|
||||
primary: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30',
|
||||
success: 'bg-[var(--success)]/20 text-[var(--success)] border border-[var(--success)]/30',
|
||||
warning: 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30',
|
||||
danger: 'bg-[var(--destructive)]/20 text-[var(--destructive)] border border-[var(--destructive)]/30',
|
||||
outline: 'bg-transparent text-[var(--muted-foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-1.5 py-0.5 text-xs rounded',
|
||||
md: 'px-2 py-1 text-xs rounded-md'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
43
src/components/ui/Button.tsx
Normal file
43
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-mono font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-[var(--primary)] hover:bg-[var(--primary)]/80 text-[var(--primary-foreground)] border border-[var(--primary)]/30 shadow-[var(--primary)]/20 shadow-lg hover:shadow-[var(--primary)]/30 focus:ring-[var(--primary)]',
|
||||
secondary: 'bg-[var(--card)] hover:bg-[var(--card-hover)] text-[var(--foreground)] border border-[var(--border)] shadow-[var(--muted)]/20 shadow-lg hover:shadow-[var(--muted)]/30 focus:ring-[var(--muted)]',
|
||||
danger: 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/80 text-white border border-[var(--destructive)]/30 shadow-[var(--destructive)]/20 shadow-lg hover:shadow-[var(--destructive)]/30 focus:ring-[var(--destructive)]',
|
||||
ghost: 'bg-transparent hover:bg-[var(--card)]/50 text-[var(--muted-foreground)] hover:text-[var(--foreground)] border border-[var(--border)]/50 hover:border-[var(--border)] focus:ring-[var(--muted)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
||||
md: 'px-4 py-2 text-sm rounded-lg',
|
||||
lg: 'px-6 py-3 text-base rounded-lg'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
81
src/components/ui/Card.tsx
Normal file
81
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'elevated' | 'bordered' | 'column';
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)]/50 border border-[var(--border)]/50',
|
||||
elevated: 'bg-[var(--card)]/80 border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20',
|
||||
bordered: 'bg-[var(--card)]/50 border border-[var(--primary)]/30 shadow-[var(--primary)]/10 shadow-lg',
|
||||
column: 'bg-[var(--card-column)] border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg backdrop-blur-sm transition-all duration-200',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-b border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-mono font-semibold text-[var(--foreground)] tracking-wide', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-t border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
||||
42
src/components/ui/FontSizeToggle.tsx
Normal file
42
src/components/ui/FontSizeToggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
|
||||
export function FontSizeToggle() {
|
||||
const { preferences, toggleFontSize } = useUserPreferences();
|
||||
|
||||
// Icône pour la taille de police
|
||||
const getFontSizeIcon = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return 'A';
|
||||
case 'large':
|
||||
return 'A';
|
||||
default:
|
||||
return 'A';
|
||||
}
|
||||
};
|
||||
|
||||
const getFontSizeScale = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return 'text-xs';
|
||||
case 'large':
|
||||
return 'text-lg';
|
||||
default:
|
||||
return 'text-sm';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleFontSize}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50 hover:text-[var(--primary)]"
|
||||
title={`Font size: ${preferences.viewPreferences.fontSize} (click to cycle)`}
|
||||
>
|
||||
<span className={`font-mono font-bold ${getFontSizeScale()}`}>
|
||||
{getFontSizeIcon()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
208
src/components/ui/Header.tsx
Normal file
208
src/components/ui/Header.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useJiraConfig } from '@/contexts/JiraConfigContext';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
syncing?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ title = "TowerControl", subtitle = "Task Management", syncing = false }: HeaderProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig();
|
||||
const pathname = usePathname();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Fonction pour déterminer si un lien est actif
|
||||
const isActiveLink = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (desktop)
|
||||
const getLinkClasses = (href: string) => {
|
||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-3 py-1.5 rounded-md";
|
||||
|
||||
if (isActiveLink(href)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
}
|
||||
|
||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (mobile)
|
||||
const getMobileLinkClasses = (href: string) => {
|
||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-4 py-3 rounded-md block w-full text-left";
|
||||
|
||||
if (isActiveLink(href)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
}
|
||||
|
||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
||||
};
|
||||
|
||||
// Liste des liens de navigation
|
||||
const navLinks = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/kanban', label: 'Kanban' },
|
||||
{ href: '/daily', label: 'Daily' },
|
||||
{ href: '/weekly-manager', label: 'Manager' },
|
||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="relative z-50 bg-[var(--card)]/80 backdrop-blur-sm border-b border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20">
|
||||
<div className="container mx-auto px-4 sm:px-6 py-4">
|
||||
{/* Layout mobile/tablette */}
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Titre et status */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0 flex-1">
|
||||
<div className={`w-3 h-3 rounded-full shadow-lg flex-shrink-0 ${
|
||||
syncing
|
||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||
}`}></div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider truncate">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-xs sm:text-sm truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls mobile/tablette */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Menu burger */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout desktop - une seule ligne comme avant */}
|
||||
<div className="hidden lg:flex items-center justify-between gap-6">
|
||||
{/* Titre et status */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4 w-[300px]">
|
||||
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
||||
syncing
|
||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||
}`}></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-sm">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
<nav className="flex items-center gap-2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={getLinkClasses(href)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Theme Toggle desktop */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Menu mobile/tablette en overlay fixe */}
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop pour fermer le menu */}
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/20 backdrop-blur-sm z-[100]"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{/* Menu */}
|
||||
<div className="lg:hidden fixed top-[80px] left-0 right-0 bg-[var(--card)]/98 backdrop-blur-md border-b border-[var(--border)]/50 shadow-xl z-[101]">
|
||||
<nav className="container mx-auto px-4 py-6">
|
||||
<div className="space-y-3">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={getMobileLinkClasses(href)}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/components/ui/HeaderContainer.tsx
Normal file
21
src/components/ui/HeaderContainer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from './Header';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
|
||||
interface HeaderContainerProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
||||
const { syncing } = useTasks();
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
syncing={syncing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/components/ui/Input.tsx
Normal file
44
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg',
|
||||
'text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50',
|
||||
'hover:border-[var(--border)] transition-all duration-200',
|
||||
'backdrop-blur-sm',
|
||||
error && 'border-[var(--destructive)]/50 focus:ring-[var(--destructive)]/50 focus:border-[var(--destructive)]/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
|
||||
<span className="text-[var(--destructive)]">⚠</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
78
src/components/ui/Modal.tsx
Normal file
78
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, children, title, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizes = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl'
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--background)]/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className={cn(
|
||||
'relative w-full mx-4 bg-[var(--card)]/95 border border-[var(--border)]/50 rounded-lg shadow-2xl shadow-[var(--card)]/50 backdrop-blur-sm',
|
||||
sizes[size]
|
||||
)}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]/50">
|
||||
<h2 className="text-lg font-mono font-semibold text-[var(--foreground)] tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors p-1"
|
||||
>
|
||||
<span className="sr-only">Fermer</span>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
157
src/components/ui/TagDisplay.tsx
Normal file
157
src/components/ui/TagDisplay.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { Tag } from '@/lib/types';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface TagDisplayProps {
|
||||
tags: string[];
|
||||
availableTags?: Tag[]; // Tags avec couleurs depuis la DB
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
maxTags?: number;
|
||||
showColors?: boolean;
|
||||
onClick?: (tagName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagDisplay({
|
||||
tags,
|
||||
availableTags = [],
|
||||
size = 'sm',
|
||||
maxTags,
|
||||
showColors = true,
|
||||
onClick,
|
||||
className = ""
|
||||
}: TagDisplayProps) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayTags = maxTags ? tags.slice(0, maxTags) : tags;
|
||||
const remainingCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
|
||||
|
||||
const getTagColor = (tagName: string): string => {
|
||||
if (!showColors) return '#6b7280'; // gray-500
|
||||
|
||||
const tag = availableTags.find(t => t.name === tagName);
|
||||
return tag?.color || '#6b7280';
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2 py-1',
|
||||
lg: 'text-base px-3 py-1.5'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||
{displayTags.map((tagName, index) => {
|
||||
const color = getTagColor(tagName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onClick?.(tagName)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border transition-colors ${sizeClasses[size]} ${
|
||||
onClick ? 'cursor-pointer hover:opacity-80' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: showColors ? `${color}20` : undefined,
|
||||
borderColor: showColors ? `${color}60` : undefined,
|
||||
color: showColors ? color : undefined
|
||||
}}
|
||||
>
|
||||
{showColors && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{tagName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="default" size="sm">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
tags: Tag[];
|
||||
onTagClick?: (tag: Tag) => void;
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant pour afficher une liste complète de tags avec actions
|
||||
*/
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagClick,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
className = ""
|
||||
}: TagListProps) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<div className="text-4xl mb-2">🏷️</div>
|
||||
<p className="text-sm">Aucun tag trouvé</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 cursor-pointer"
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-slate-200 font-medium">{tag.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="p-1 text-slate-400 hover:text-cyan-400 transition-colors"
|
||||
title="Éditer le tag"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="Supprimer le tag"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/ui/TagInput.tsx
Normal file
199
src/components/ui/TagInput.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { useTagsAutocomplete } from '@/hooks/useTags';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxTags?: number;
|
||||
className?: string;
|
||||
compactSuggestions?: boolean; // Pour adapter selon l'espace
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
tags,
|
||||
onChange,
|
||||
placeholder = "Ajouter des tags...",
|
||||
maxTags = 10,
|
||||
className = "",
|
||||
compactSuggestions = false
|
||||
}: TagInputProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
|
||||
|
||||
// Rechercher des suggestions quand l'input change
|
||||
useEffect(() => {
|
||||
if (inputValue.trim()) {
|
||||
searchTags(inputValue);
|
||||
setShowSuggestions(true);
|
||||
setSelectedIndex(-1);
|
||||
} else {
|
||||
clearSuggestions();
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [inputValue, searchTags, clearSuggestions]);
|
||||
|
||||
const addTag = (tagName: string) => {
|
||||
const trimmedTag = tagName.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||||
onChange([...tags, trimmedTag]);
|
||||
}
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
||||
addTag(suggestions[selectedIndex].name);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||||
// Supprimer le dernier tag si l'input est vide
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (tag: Tag) => {
|
||||
addTag(tag.name);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
// Délai pour permettre le clic sur une suggestion
|
||||
setTimeout(() => {
|
||||
if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (inputValue.trim()) {
|
||||
// Si il y a du texte, afficher les suggestions existantes
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
// Si l'input est vide, charger les tags populaires
|
||||
loadPopularTags(20);
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Container des tags et input */}
|
||||
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{/* Tags existants */}
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="default"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
||||
aria-label={`Supprimer le tag ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Input pour nouveau tag */}
|
||||
{tags.length < maxTags && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
placeholder={tags.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--foreground)] placeholder-[var(--muted-foreground)] text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-[9999] max-h-64 overflow-y-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
|
||||
Recherche...
|
||||
</div>
|
||||
) : (
|
||||
<div className={`gap-2 p-3 ${compactSuggestions ? 'grid grid-cols-1' : 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'}`}>
|
||||
{suggestions.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(tag)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-[var(--card-hover)] text-[var(--primary)] ring-1 ring-[var(--primary)]'
|
||||
: 'text-[var(--foreground)] hover:bg-[var(--card-hover)]'
|
||||
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={tags.includes(tag.name)}
|
||||
title={tags.includes(tag.name) ? 'Déjà ajouté' : `Ajouter ${tag.name}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="truncate">{tag.name}</span>
|
||||
{tags.includes(tag.name) && (
|
||||
<span className="text-[var(--muted-foreground)] ml-auto">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de limite */}
|
||||
{tags.length >= maxTags && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Limite de {maxTags} tags atteinte
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/ui/TagList.tsx
Normal file
95
src/components/ui/TagList.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface TagListProps {
|
||||
tags: (Tag & { usage?: number })[];
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
showActions?: boolean;
|
||||
showUsage?: boolean;
|
||||
deletingTagId?: string | null;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
showActions = true,
|
||||
deletingTagId
|
||||
}: TagListProps) {
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-200 font-medium truncate">
|
||||
{tag.name}
|
||||
</h3>
|
||||
{tag.usage !== undefined && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
||||
{tag.usage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions (apparaissent au hover) */}
|
||||
{showActions && (onTagEdit || onTagDelete) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
disabled={isDeleting}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de couleur en bas */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user