Add foundational components and logic for migration, UI design, and input handling
- Introduced foundational UI components (`Badge`, `LockCard`, `SectionHeader`, `AvatarIcon`, etc.) for flexible layouts and consistent design. - Added migration support with the `MigrationModal` component and backend integration for exporting/importing data between Electron and Tauri. - Extended form components with `TextAreaInput`, `OrderInput`, `ToggleField`, and `ToolbarSelect` for improved input handling. - Updated `ScribeShell` with migration popup logic to prompt users for data migration. - Integrated `AlertStack` for better alert handling and notification management. - Enhanced Rust/Tauri services with migration command implementations. - Added translations and styles for new components.
This commit is contained in:
123
components/ui/AlertBox.tsx
Normal file
123
components/ui/AlertBox.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {AlertTriangle, Check, Info, LucideIcon, X} from 'lucide-react';
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
export type AlertType = 'alert' | 'danger' | 'informatif' | 'success';
|
||||
|
||||
interface AlertBoxProps {
|
||||
title: string;
|
||||
message: string;
|
||||
type: AlertType;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface AlertConfig {
|
||||
background: string;
|
||||
borderColor: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
export default function AlertBox(
|
||||
{
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
confirmText = 'Confirmer',
|
||||
cancelText = 'Annuler',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: AlertBoxProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
function getButtonVariant(alertType: AlertType): 'warning' | 'danger' | 'info' | 'success' {
|
||||
switch (alertType) {
|
||||
case 'alert':
|
||||
return 'warning';
|
||||
case 'danger':
|
||||
return 'danger';
|
||||
case 'informatif':
|
||||
return 'info';
|
||||
case 'success':
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
function getAlertConfig(alertType: AlertType): AlertConfig {
|
||||
switch (alertType) {
|
||||
case 'alert':
|
||||
return {
|
||||
background: 'bg-warning',
|
||||
borderColor: 'border-warning/30',
|
||||
icon: AlertTriangle,
|
||||
iconBg: 'bg-warning/10'
|
||||
};
|
||||
case 'danger':
|
||||
return {
|
||||
background: 'bg-error',
|
||||
borderColor: 'border-error/30',
|
||||
icon: X,
|
||||
iconBg: 'bg-error/10'
|
||||
};
|
||||
case 'informatif':
|
||||
return {
|
||||
background: 'bg-info',
|
||||
borderColor: 'border-info/30',
|
||||
icon: Info,
|
||||
iconBg: 'bg-info/10'
|
||||
};
|
||||
case 'success':
|
||||
default:
|
||||
return {
|
||||
background: 'bg-success',
|
||||
borderColor: 'border-success/30',
|
||||
icon: Check,
|
||||
iconBg: 'bg-success/10'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const alertSettings = getAlertConfig(type);
|
||||
const AlertIcon = alertSettings.icon;
|
||||
|
||||
const alertContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className="relative w-full max-w-md rounded-2xl bg-tertiary overflow-hidden">
|
||||
<div className={`${alertSettings.background} px-6 py-4`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl ${alertSettings.iconBg} flex items-center justify-center`}>
|
||||
<AlertIcon className="w-6 h-6 text-text-primary" strokeWidth={1.75}/>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary tracking-wide">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-darkest-background">
|
||||
<p className="mb-6 text-text-primary whitespace-pre-line leading-relaxed">{message}</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onCancel}>{cancelText}</Button>
|
||||
<Button variant={getButtonVariant(type)} onClick={onConfirm}>{confirmText}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(alertContent, document.body);
|
||||
}
|
||||
44
components/ui/AlertStack.tsx
Normal file
44
components/ui/AlertStack.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import StaticAlert from '@/components/ui/StaticAlert';
|
||||
import {Alert} from '@/context/AlertProvider';
|
||||
|
||||
interface AlertStackProps {
|
||||
alerts: Alert[];
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function AlertStack({alerts, onClose}: AlertStackProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const alertContent = (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none">
|
||||
{alerts.map((alert, index) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="pointer-events-auto alert-slide-in"
|
||||
ref={(el: HTMLDivElement | null): void => {
|
||||
if (el) el.style.animationDelay = `${index * 50}ms`;
|
||||
}}
|
||||
>
|
||||
<StaticAlert
|
||||
type={alert.type}
|
||||
message={alert.message}
|
||||
onClose={() => onClose(alert.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(alertContent, document.body);
|
||||
}
|
||||
65
components/ui/AvatarIcon.tsx
Normal file
65
components/ui/AvatarIcon.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import {LucideIcon} from 'lucide-react';
|
||||
|
||||
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface AvatarIconProps {
|
||||
size?: AvatarSize;
|
||||
image?: string | null;
|
||||
icon?: LucideIcon;
|
||||
initial?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<AvatarSize, string> = {
|
||||
xs: 'w-7 h-7',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-14 h-14',
|
||||
xl: 'w-16 h-16',
|
||||
};
|
||||
|
||||
const iconSizeClasses: Record<AvatarSize, string> = {
|
||||
xs: 'w-3.5 h-3.5',
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-7 h-7',
|
||||
xl: 'w-8 h-8',
|
||||
};
|
||||
|
||||
const initialSizeClasses: Record<AvatarSize, string> = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
};
|
||||
|
||||
export default function AvatarIcon({
|
||||
size = 'md',
|
||||
image,
|
||||
icon: Icon,
|
||||
initial,
|
||||
alt = '',
|
||||
}: AvatarIconProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-full border-2 border-primary overflow-hidden bg-secondary transition-colors flex items-center justify-center`}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className={`text-primary ${iconSizeClasses[size]}`} strokeWidth={1.75}/>
|
||||
) : initial ? (
|
||||
<div
|
||||
className={`w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold ${initialSizeClasses[size]}`}>
|
||||
{initial}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
components/ui/Badge.tsx
Normal file
61
components/ui/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {LucideIcon} from 'lucide-react';
|
||||
|
||||
type BadgeVariant = 'primary' | 'success' | 'warning' | 'error' | 'muted';
|
||||
type BadgeSize = 'sm' | 'md';
|
||||
type BadgeShape = 'pill' | 'rounded';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
shape?: BadgeShape;
|
||||
icon?: LucideIcon;
|
||||
children?: ReactNode;
|
||||
interactive?: boolean;
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
primary: 'bg-primary/20 text-primary border border-primary/30',
|
||||
success: 'bg-success/20 text-success border border-success/30',
|
||||
warning: 'bg-warning/20 text-warning border border-warning/30',
|
||||
error: 'bg-error/20 text-error border border-error/30',
|
||||
muted: 'bg-secondary text-muted border border-secondary',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<BadgeSize, string> = {
|
||||
sm: 'text-[10px] px-2 py-0.5',
|
||||
md: 'text-xs px-3 py-1',
|
||||
};
|
||||
|
||||
const shapeClasses: Record<BadgeShape, string> = {
|
||||
pill: 'rounded-full',
|
||||
rounded: 'rounded-lg',
|
||||
};
|
||||
|
||||
export default function Badge(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
shape = 'pill',
|
||||
icon: Icon,
|
||||
children,
|
||||
interactive = false,
|
||||
floating = false,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
font-medium inline-flex items-center gap-1.5
|
||||
${shapeClasses[shape]}
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${interactive ? 'cursor-pointer group transition-colors' : ''}
|
||||
${floating ? 'absolute top-3 right-3' : ''}
|
||||
`}
|
||||
>
|
||||
{Icon && <Icon className="w-3 h-3" strokeWidth={1.75}/>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
84
components/ui/Button.tsx
Normal file
84
components/ui/Button.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {Loader2, LucideIcon} from "lucide-react";
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'warning' | 'info' | 'success' | 'dashed';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
onClick?: () => void | Promise<void>;
|
||||
type?: 'button' | 'submit';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-primary-dark text-text-primary hover:bg-primary',
|
||||
secondary: 'bg-secondary text-text-primary border border-secondary hover:bg-gray-dark hover:border-gray-dark',
|
||||
danger: 'bg-error text-text-primary hover:bg-error/80',
|
||||
warning: 'bg-warning text-text-primary hover:bg-warning/80',
|
||||
info: 'bg-info text-text-primary hover:bg-info/80',
|
||||
success: 'bg-success text-text-primary hover:bg-success/80',
|
||||
ghost: 'text-muted hover:text-primary hover:bg-primary/10',
|
||||
dashed: 'border-2 border-dashed border-secondary bg-dark-background hover:bg-tertiary text-text-primary',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
|
||||
md: 'px-5 py-2.5 text-sm rounded-xl gap-2',
|
||||
lg: 'px-6 py-3 text-base rounded-xl gap-2',
|
||||
};
|
||||
|
||||
export default function Button(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
disabled = false,
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
}: ButtonProps) {
|
||||
const isDisabled: boolean = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
font-semibold transition-all duration-200
|
||||
flex items-center justify-center relative overflow-hidden
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-2 transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{Icon && (
|
||||
<Icon className="w-4 h-4" strokeWidth={1.75}/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.75}/>
|
||||
{loadingText && (
|
||||
<span className="ml-2 text-sm font-medium">{loadingText}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
65
components/ui/Collapse.tsx
Normal file
65
components/ui/Collapse.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, {ReactNode, useState} from "react";
|
||||
import {ChevronDown, ChevronUp, LucideIcon} from 'lucide-react';
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
type CollapseVariant = 'default' | 'card';
|
||||
|
||||
export interface CollapseProps {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: CollapseVariant;
|
||||
content?: ReactNode;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export default function Collapse(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
variant = 'default',
|
||||
content,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: CollapseProps) {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(defaultOpen);
|
||||
const renderContent: ReactNode = children || content;
|
||||
const isCard: boolean = variant === 'card';
|
||||
const ChevronIcon = isOpen ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-4 transition-colors duration-150 ${isCard ? 'bg-tertiary rounded-xl' : ''}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full text-left transition-all duration-200 p-4 flex items-center justify-between group
|
||||
${isCard
|
||||
? ''
|
||||
: `bg-secondary hover:bg-gray-dark ${isOpen ? 'rounded-t-xl' : 'rounded-xl'}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<div className="transition-colors duration-150">
|
||||
<IconContainer icon={icon} size="sm" shape="circle" filled/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-text-primary font-bold">{title}</span>
|
||||
</div>
|
||||
<ChevronIcon className="text-primary w-4 h-4 transition-transform"
|
||||
strokeWidth={1.75}/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={`animate-fadeIn ${isCard
|
||||
? 'p-3 m-4 mt-0 bg-darkest-background rounded-lg'
|
||||
: 'bg-darkest-background border-l-4 border-primary p-4 rounded-b-xl'
|
||||
}`}>
|
||||
{renderContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/ui/CreditMeters.tsx
Normal file
29
components/ui/CreditMeters.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, {useContext} from "react";
|
||||
import {Coins, DollarSign} from "lucide-react";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
export default function CreditCounter({isCredit}: { isCredit: boolean }) {
|
||||
const {totalCredits, totalPrice}: AIUsageContextProps = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
|
||||
if (isCredit) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
|
||||
<Coins className="w-4 h-4 text-warning" strokeWidth={1.75}/>
|
||||
<span className="text-sm text-text-primary font-medium">
|
||||
{Math.round(totalCredits)} crédits
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
|
||||
<DollarSign className="w-4 h-4 text-primary" strokeWidth={1.75}/>
|
||||
<span className="text-sm text-text-primary font-medium">
|
||||
{totalPrice ? totalPrice.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/ui/DetailField.tsx
Normal file
51
components/ui/DetailField.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import {LucideIcon} from 'lucide-react';
|
||||
|
||||
type DetailFieldVariant = 'default' | 'compact';
|
||||
|
||||
interface DetailFieldProps {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
icon?: LucideIcon;
|
||||
variant?: DetailFieldVariant;
|
||||
preserveWhitespace?: boolean;
|
||||
}
|
||||
|
||||
export default function DetailField({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
variant = 'default',
|
||||
preserveWhitespace = true,
|
||||
}: DetailFieldProps): React.JSX.Element | null {
|
||||
if (variant === 'compact' && !value) return null;
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className={`text-text-primary text-sm ${preserveWhitespace ? 'whitespace-pre-wrap' : ''}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIcon(): React.JSX.Element | null {
|
||||
if (!icon) return null;
|
||||
const Icon: LucideIcon = icon;
|
||||
return <Icon className="w-4 h-4 text-primary" strokeWidth={1.75}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5 bg-secondary rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{renderIcon()}
|
||||
<h3 className="text-text-primary font-semibold">{label}</h3>
|
||||
</div>
|
||||
<p className={`${preserveWhitespace ? 'whitespace-pre-wrap' : ''} ${value ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{value || '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
components/ui/DetailHeroSection.tsx
Normal file
26
components/ui/DetailHeroSection.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {LucideIcon} from 'lucide-react';
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
interface DetailHeroSectionProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DetailHeroSection({icon, title, children}: DetailHeroSectionProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="p-6 bg-gradient-to-r from-primary/10 via-darkest-background to-transparent rounded-2xl border border-secondary">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0">
|
||||
<IconContainer icon={icon} size="lg" shape="square"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
components/ui/Dropdown.tsx
Normal file
89
components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, {ReactNode, useEffect, useRef, useState} from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
|
||||
interface DropdownItem {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
items: DropdownItem[];
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function Dropdown(
|
||||
{
|
||||
trigger,
|
||||
items,
|
||||
align = 'left',
|
||||
}: DropdownProps): React.JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const dropdownRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(function handleClickOutside() {
|
||||
function onClickOutside(event: MouseEvent): void {
|
||||
if (dropdownRef.current && event.target instanceof Node && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return (): void => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
function handleItemClick(item: DropdownItem): void {
|
||||
item.onClick();
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<div onClick={(): void => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`
|
||||
absolute top-full mt-2 z-50 min-w-48
|
||||
bg-tertiary rounded-xl py-2
|
||||
border border-secondary backdrop-blur-sm animate-fadeIn
|
||||
${align === 'right' ? 'right-0' : 'left-0'}
|
||||
`}
|
||||
>
|
||||
{items.map(function renderDropdownItem(item: DropdownItem, index: number) {
|
||||
const isDanger: boolean = item.variant === 'danger';
|
||||
const ItemIcon: LucideIcon | undefined = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(): void => handleItemClick(item)}
|
||||
className={`
|
||||
group w-full flex items-center gap-3 px-4 py-2.5
|
||||
transition-all hover:pl-5
|
||||
${isDanger
|
||||
? 'text-error hover:bg-error/10'
|
||||
: 'text-text-primary hover:bg-secondary'
|
||||
}
|
||||
${index === 0 ? 'rounded-t-xl' : ''}
|
||||
${index === items.length - 1 ? 'rounded-b-xl' : ''}
|
||||
`}
|
||||
>
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
className={`w-4 h-4 ${isDanger ? 'text-error' : 'text-muted group-hover:text-primary'}`}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/ui/EmptyState.tsx
Normal file
33
components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export default function EmptyState(
|
||||
{
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center py-12 text-center p-8">
|
||||
<div className="mb-6">
|
||||
<IconContainer icon={icon} size="xl" shape="rounded"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted max-w-md text-lg leading-relaxed">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<div className="mt-6">{action}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
components/ui/EntityListItem.tsx
Normal file
86
components/ui/EntityListItem.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
|
||||
type EntityListItemSize = 'sm' | 'md';
|
||||
type EntityListItemVariant = 'default' | 'transparent';
|
||||
|
||||
interface EntityListItemProps {
|
||||
onClick: () => void;
|
||||
avatar: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
extra?: ReactNode;
|
||||
size?: EntityListItemSize;
|
||||
variant?: EntityListItemVariant;
|
||||
}
|
||||
|
||||
const rowClasses: Record<EntityListItemSize, Record<EntityListItemVariant, string>> = {
|
||||
sm: {
|
||||
default: 'group flex items-center p-3 bg-tertiary rounded-lg cursor-pointer hover:bg-secondary transition-colors duration-150',
|
||||
transparent: 'group flex items-center p-3 rounded-lg cursor-pointer hover:bg-dark-background transition-colors duration-150',
|
||||
},
|
||||
md: {
|
||||
default: 'group flex items-center p-4 bg-tertiary rounded-xl cursor-pointer hover:bg-secondary transition-colors duration-150',
|
||||
transparent: 'group flex items-center p-4 rounded-xl cursor-pointer hover:bg-dark-background transition-colors duration-150',
|
||||
},
|
||||
};
|
||||
|
||||
const contentClasses: Record<EntityListItemSize, { margin: string; title: string; subtitle: string }> = {
|
||||
sm: {
|
||||
margin: 'ml-3',
|
||||
title: 'text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate',
|
||||
subtitle: 'text-muted text-xs truncate',
|
||||
},
|
||||
md: {
|
||||
margin: 'ml-4',
|
||||
title: 'text-text-primary font-bold text-base group-hover:text-primary transition-colors',
|
||||
subtitle: 'text-text-secondary text-sm mt-0.5 truncate',
|
||||
},
|
||||
};
|
||||
|
||||
const chevronClasses: Record<EntityListItemSize, { container: string; icon: string }> = {
|
||||
sm: {
|
||||
container: 'w-6 flex justify-center',
|
||||
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3',
|
||||
},
|
||||
md: {
|
||||
container: 'w-8 flex justify-center',
|
||||
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EntityListItem({
|
||||
onClick,
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
extra,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
}: EntityListItemProps): React.JSX.Element {
|
||||
const content: { margin: string; title: string; subtitle: string } = contentClasses[size];
|
||||
const chevron: { container: string; icon: string } = chevronClasses[size];
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className={rowClasses[size][variant]}>
|
||||
{avatar}
|
||||
|
||||
<div className={`${content.margin} flex-1 min-w-0`}>
|
||||
<div className={content.title}>{title}</div>
|
||||
{subtitle && (
|
||||
<div className={content.subtitle}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extra && (
|
||||
<div className="px-3">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={chevron.container}>
|
||||
<ChevronRight className={chevron.icon} strokeWidth={1.75}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/ui/IconButton.tsx
Normal file
69
components/ui/IconButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
|
||||
type IconButtonVariant = 'primary' | 'danger' | 'ghost' | 'muted' | 'light';
|
||||
type IconButtonSize = 'sm' | 'md' | 'lg';
|
||||
type IconButtonShape = 'circle' | 'square';
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: LucideIcon;
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
shape?: IconButtonShape;
|
||||
onClick?: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<IconButtonVariant, string> = {
|
||||
primary: 'text-muted hover:text-primary hover:bg-primary/10',
|
||||
danger: 'text-muted hover:text-error hover:bg-error/10',
|
||||
ghost: 'text-text-secondary hover:text-text-primary hover:bg-secondary',
|
||||
muted: 'bg-secondary hover:bg-gray-dark text-text-secondary hover:text-text-primary',
|
||||
light: 'text-text-primary/80 hover:text-text-primary hover:bg-text-primary/10',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<IconButtonSize, { button: string; icon: string }> = {
|
||||
sm: {button: 'p-1.5', icon: 'w-4 h-4'},
|
||||
md: {button: 'p-2', icon: 'w-5 h-5'},
|
||||
lg: {button: 'p-2', icon: 'w-6 h-6'},
|
||||
};
|
||||
|
||||
const shapeClasses: Record<IconButtonShape, string> = {
|
||||
circle: 'rounded-full',
|
||||
square: 'rounded-lg',
|
||||
};
|
||||
|
||||
export default function IconButton(
|
||||
{
|
||||
icon: Icon,
|
||||
variant = 'ghost',
|
||||
size = 'md',
|
||||
shape = 'square',
|
||||
onClick,
|
||||
disabled = false,
|
||||
selected = false,
|
||||
tooltip,
|
||||
}: IconButtonProps) {
|
||||
const sizeConfig = sizeClasses[size];
|
||||
const selectedClass: string = selected ? 'bg-secondary text-primary' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={tooltip}
|
||||
className={`
|
||||
flex items-center justify-center
|
||||
transition-all duration-200
|
||||
${shapeClasses[shape]}
|
||||
${selected ? selectedClass : variantClasses[variant]}
|
||||
${sizeConfig.button}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Icon className={sizeConfig.icon} strokeWidth={2.25}/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
47
components/ui/IconContainer.tsx
Normal file
47
components/ui/IconContainer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
|
||||
type IconContainerSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
type IconContainerShape = 'square' | 'rounded' | 'circle';
|
||||
|
||||
interface IconContainerProps {
|
||||
icon: LucideIcon;
|
||||
size?: IconContainerSize;
|
||||
shape?: IconContainerShape;
|
||||
filled?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<IconContainerSize, { container: string; icon: string }> = {
|
||||
sm: {container: 'w-10 h-10', icon: 'w-4 h-4'},
|
||||
md: {container: 'w-12 h-12', icon: 'w-5 h-5'},
|
||||
lg: {container: 'w-16 h-16', icon: 'w-8 h-8'},
|
||||
xl: {container: 'w-20 h-20', icon: 'w-10 h-10'},
|
||||
};
|
||||
|
||||
const shapeClasses: Record<IconContainerShape, string> = {
|
||||
square: 'rounded-lg',
|
||||
rounded: 'rounded-2xl',
|
||||
circle: 'rounded-full',
|
||||
};
|
||||
|
||||
export default function IconContainer(
|
||||
{
|
||||
icon: Icon,
|
||||
size = 'sm',
|
||||
shape = 'square',
|
||||
filled = false,
|
||||
}: IconContainerProps) {
|
||||
const sizeConfig = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${filled ? 'bg-primary' : 'bg-primary/10'} flex items-center justify-center
|
||||
${sizeConfig.container}
|
||||
${shapeClasses[shape]}
|
||||
`}
|
||||
>
|
||||
<Icon className={`${filled ? 'text-text-primary' : 'text-primary'} ${sizeConfig.icon}`} strokeWidth={1.75}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
components/ui/IconLabel.tsx
Normal file
16
components/ui/IconLabel.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
|
||||
interface IconLabelProps {
|
||||
icon: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function IconLabel({icon: Icon, children}: IconLabelProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
components/ui/InsetPanel.tsx
Normal file
14
components/ui/InsetPanel.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, {ReactNode} from "react";
|
||||
|
||||
interface InsetPanelProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InsetPanel({children, className = ''}: InsetPanelProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={`flex-1 bg-darkest-background rounded-xl m-1 overflow-auto flex flex-col ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
components/ui/ListItem.tsx
Normal file
121
components/ui/ListItem.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {ArrowDown, ArrowUp, Check, LucideIcon, Pen, Trash2, X} from "lucide-react";
|
||||
import React, {ChangeEvent, useState} from "react";
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
interface ListItemProps {
|
||||
onClick: () => void;
|
||||
selectedId: number | string;
|
||||
id: number | string;
|
||||
icon?: LucideIcon;
|
||||
numericalIdentifier?: number;
|
||||
isEditable?: boolean;
|
||||
text: string;
|
||||
handleDelete?: (itemId: string) => void;
|
||||
handleUpdate?: (itemId: string, newValue: string, subNewValue: number) => void;
|
||||
onReorder?: (itemId: string, newOrder: number) => void;
|
||||
}
|
||||
|
||||
export default function ListItem(
|
||||
{
|
||||
text,
|
||||
selectedId,
|
||||
id,
|
||||
icon,
|
||||
onClick,
|
||||
isEditable = false,
|
||||
handleDelete,
|
||||
numericalIdentifier,
|
||||
handleUpdate,
|
||||
onReorder
|
||||
}: ListItemProps): React.JSX.Element {
|
||||
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [newChapterOrder, setNewChapterOrder] = useState<number>(numericalIdentifier ?? 0);
|
||||
|
||||
function handleEdit(itemName: string): void {
|
||||
setNewName(itemName)
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
function handleSave(): void {
|
||||
if (!handleUpdate) return;
|
||||
handleUpdate(String(id), newName, newChapterOrder)
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
function moveItem(direction: "up" | "down"): void {
|
||||
const nextOrder: number = direction === "up" ? newChapterOrder - 1 : newChapterOrder + 1;
|
||||
if (nextOrder < 0) return;
|
||||
setNewChapterOrder(nextOrder);
|
||||
if (onReorder) {
|
||||
onReorder(String(id), nextOrder);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`group relative flex items-center p-3 rounded-xl transition-colors duration-200 ${
|
||||
selectedId === id
|
||||
? 'bg-tertiary text-text-primary'
|
||||
: 'hover:bg-tertiary'
|
||||
}`}>
|
||||
{(numericalIdentifier != null && newChapterOrder >= 0) && (
|
||||
<span className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5 shrink-0 mr-2">
|
||||
{newChapterOrder}
|
||||
</span>
|
||||
)}
|
||||
{icon && (
|
||||
<IconContainer icon={icon} size="sm" shape="square"/>
|
||||
)}
|
||||
<div className="flex items-center w-full gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={newName || text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-sm font-medium text-text-primary outline-none min-w-0"
|
||||
/>
|
||||
<div className="flex shrink-0">
|
||||
<button onClick={(): void => moveItem('up')} disabled={newChapterOrder <= 0}
|
||||
className="text-muted hover:text-text-primary disabled:opacity-30 p-0.5">
|
||||
<ArrowUp className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
</button>
|
||||
<button onClick={(): void => moveItem('down')}
|
||||
className="text-muted hover:text-text-primary p-0.5">
|
||||
<ArrowDown className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
</button>
|
||||
<button onClick={handleSave}
|
||||
className="text-primary hover:text-primary/80 p-0.5">
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
</button>
|
||||
<button onClick={(): void => setEditMode(false)}
|
||||
className="text-muted hover:text-text-primary p-0.5">
|
||||
<X className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="cursor-pointer text-sm font-medium flex-1 group-hover:text-text-primary transition-colors"
|
||||
onClick={onClick}>{text}</span>
|
||||
)}
|
||||
{!editMode && isEditable && (
|
||||
<div className="absolute right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={(): void => handleEdit(text)}
|
||||
className="text-muted hover:text-text-primary p-1.5 rounded-lg hover:bg-secondary">
|
||||
<Pen className="w-4 h-4" strokeWidth={1.75}/>
|
||||
</button>
|
||||
<button onClick={(): void => {
|
||||
if (handleDelete) handleDelete(id.toString());
|
||||
}}
|
||||
className="text-muted hover:text-error p-1.5 rounded-lg hover:bg-error/10">
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.75}/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
30
components/ui/LockCard.tsx
Normal file
30
components/ui/LockCard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import {Lock} from 'lucide-react';
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
interface LockCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function LockCard({title, description}: LockCardProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-tertiary">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="bg-tertiary rounded-2xl p-8 text-center">
|
||||
<div className="mx-auto mb-6">
|
||||
<IconContainer icon={Lock} size="xl" shape="rounded" filled/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
components/ui/Modal.tsx
Normal file
102
components/ui/Modal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
import React, {ReactNode, useEffect, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {LucideIcon, X} from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
size?: ModalSize;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
footer?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
enableOverflow?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12',
|
||||
md: 'md:w-3/4 xl:w-2/5 lg:w-2/4 sm:w-11/12',
|
||||
lg: 'md:w-3/4 xl:w-3/5 lg:w-3/4 sm:w-11/12',
|
||||
};
|
||||
|
||||
export default function Modal(
|
||||
{
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
size = 'md',
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmText = 'Confirmer',
|
||||
cancelText = 'Annuler',
|
||||
footer,
|
||||
actions,
|
||||
enableOverflow = true,
|
||||
}: ModalProps) {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
setMounted(true);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return (): void => {
|
||||
setMounted(false);
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasFooter: boolean = !!footer || !!onConfirm;
|
||||
|
||||
const modalContent: React.JSX.Element = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className={`relative bg-tertiary text-text-primary rounded-xl max-h-[90vh] overflow-hidden flex flex-col ${sizeClasses[size]}`}>
|
||||
<div className="flex justify-between items-center px-6 py-4">
|
||||
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-xl tracking-wide">
|
||||
{Icon && <Icon className="w-6 h-6" strokeWidth={1.75}/>}
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
<IconButton icon={X} variant="light" onClick={onClose}/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 min-h-0 bg-darkest-background rounded-xl mx-2 flex flex-col overflow-hidden ${!hasFooter ? 'mb-2' : ''}`}>
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${enableOverflow ? 'overflow-auto custom-scrollbar' : 'overflow-hidden'}`}>
|
||||
<div className="p-5 space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{footer ? footer : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
30
components/ui/PulseLoader.tsx
Normal file
30
components/ui/PulseLoader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
type PulseLoaderSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface PulseLoaderProps {
|
||||
text?: string;
|
||||
size?: PulseLoaderSize;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<PulseLoaderSize, { container: string; icon: string }> = {
|
||||
sm: {container: 'py-8', icon: 'w-6 h-6'},
|
||||
md: {container: 'h-32', icon: 'w-8 h-8'},
|
||||
lg: {container: 'h-64', icon: 'w-10 h-10'},
|
||||
};
|
||||
|
||||
export default function PulseLoader({text, size = 'md'}: PulseLoaderProps): React.JSX.Element {
|
||||
const sizeConfig: { container: string; icon: string } = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${sizeConfig.container}`}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader2 className={`${sizeConfig.icon} text-primary animate-spin mb-3`} strokeWidth={1.75}/>
|
||||
{text && (
|
||||
<p className="text-text-secondary">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
components/ui/QSTextGeneratedPreview.tsx
Normal file
98
components/ui/QSTextGeneratedPreview.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import {RefreshCw, Send, Square} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
interface QSTextGeneratedPreviewProps {
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
value: string;
|
||||
onInsert: () => void;
|
||||
isGenerating?: boolean;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export default function QSTextGeneratedPreview(
|
||||
{
|
||||
onClose,
|
||||
onRefresh,
|
||||
value,
|
||||
onInsert,
|
||||
isGenerating = false,
|
||||
onStop,
|
||||
}: QSTextGeneratedPreviewProps) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const filteredValue: string = value.replace(/^starting\.{0,3}\s*/i, '').trim();
|
||||
const hasRealContent: boolean = filteredValue.length > 0;
|
||||
|
||||
const headerActions: React.ReactNode = isGenerating && onStop ? (
|
||||
<IconButton icon={Square} variant="danger" onClick={onStop}/>
|
||||
) : (
|
||||
<IconButton icon={RefreshCw} variant="ghost" onClick={onRefresh}/>
|
||||
);
|
||||
|
||||
const footerContent: React.ReactNode = (
|
||||
<Button variant="primary" onClick={onInsert} icon={Send}>
|
||||
{t("qsTextPreview.insert")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("qsTextPreview.title")}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
actions={headerActions}
|
||||
footer={footerContent}
|
||||
enableOverflow={true}
|
||||
>
|
||||
<div className="font-['Lora']">
|
||||
{isGenerating && !hasRealContent ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-8"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-7"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/15 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-8"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-primary text-justify leading-relaxed whitespace-pre-wrap fade-in-text">
|
||||
{filteredValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
43
components/ui/SectionHeader.tsx
Normal file
43
components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import IconContainer from "@/components/ui/IconContainer";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
badge?: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SectionHeader(
|
||||
{
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl text-text-primary font-bold flex items-center gap-3 flex-wrap">
|
||||
{icon && <IconContainer icon={icon} size="sm"/>}
|
||||
<span className="tracking-wide">{title}</span>
|
||||
{badge && <Badge>{badge}</Badge>}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-text-secondary text-xs mt-2 ml-13">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
components/ui/SettingsPanel.tsx
Normal file
53
components/ui/SettingsPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {X} from "lucide-react";
|
||||
import {createPortal} from "react-dom";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
title: string;
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({title, sidebar, children, onClose}: SettingsPanelProps) {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
useEffect((): void => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-40 bg-darkest-background/60 backdrop-blur-md flex items-center justify-center">
|
||||
<div className="w-3/4 h-[85vh] bg-tertiary rounded-2xl border border-secondary flex flex-col">
|
||||
|
||||
<div className="px-6 py-4 rounded-t-2xl flex justify-between items-center">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-primary/80 hover:text-text-primary p-2 rounded-lg hover:bg-text-primary/10 transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" strokeWidth={1.75}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-56 flex-shrink-0 overflow-y-auto">
|
||||
{sidebar}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 p-2 pl-0">
|
||||
<div className="h-full bg-darkest-background rounded-xl overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
73
components/ui/StaticAlert.tsx
Normal file
73
components/ui/StaticAlert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {CheckCircle, AlertCircle, Info, AlertTriangle, X, LucideIcon} from 'lucide-react';
|
||||
|
||||
interface StaticAlertProps {
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const iconMap: Record<StaticAlertProps['type'], LucideIcon> = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
const accentColorMap: Record<StaticAlertProps['type'], string> = {
|
||||
success: 'text-success border-l-success',
|
||||
error: 'text-error border-l-error',
|
||||
info: 'text-info border-l-info',
|
||||
warning: 'text-warning border-l-warning',
|
||||
};
|
||||
|
||||
export default function StaticAlert({type, message, onClose}: StaticAlertProps) {
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
|
||||
useEffect((): void => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
setVisible(true);
|
||||
const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
|
||||
setVisible(false);
|
||||
setTimeout((): void => onCloseRef.current(), 300);
|
||||
}, 4800);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleClose(): void {
|
||||
setVisible(false);
|
||||
setTimeout((): void => onCloseRef.current(), 300);
|
||||
}
|
||||
|
||||
const AlertIcon: LucideIcon = iconMap[type];
|
||||
const accentClasses: string = accentColorMap[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-sm rounded-xl transition-all duration-300 ease-in-out transform ${
|
||||
visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||
} bg-darkest-background border-l-4 ${accentClasses}`}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center gap-3">
|
||||
<AlertIcon className="w-5 h-5 flex-shrink-0" strokeWidth={2}/>
|
||||
<span className="flex-grow text-text-primary text-sm font-medium">
|
||||
{typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted hover:text-text-primary p-1 rounded-lg hover:bg-secondary transition-all duration-200 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" strokeWidth={2}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
components/ui/ToggleGroup.tsx
Normal file
59
components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import {LucideIcon} from "lucide-react";
|
||||
|
||||
interface ToggleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type ToggleGroupSize = 'sm' | 'md';
|
||||
|
||||
interface ToggleGroupProps {
|
||||
options: ToggleOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
icon?: LucideIcon;
|
||||
size?: ToggleGroupSize;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ToggleGroupSize, { wrapper: string; icon: string; button: string }> = {
|
||||
sm: {wrapper: 'rounded-lg', icon: 'w-3.5 h-3.5 mx-2', button: 'px-2.5 py-1 text-xs'},
|
||||
md: {wrapper: 'rounded-xl', icon: 'w-4 h-4 mx-2.5', button: 'px-3 py-1.5 text-sm'},
|
||||
};
|
||||
|
||||
export default function ToggleGroup(
|
||||
{
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
size = 'sm'
|
||||
}: ToggleGroupProps): React.JSX.Element {
|
||||
const config = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center bg-darkest-background ${config.wrapper} border border-secondary`}>
|
||||
{Icon && (
|
||||
<Icon className={`${config.icon} text-primary flex-shrink-0`} strokeWidth={1.75}/>
|
||||
)}
|
||||
{options.map(function (option: ToggleOption): React.JSX.Element {
|
||||
const isActive: boolean = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={function (): void {
|
||||
onChange(option.value);
|
||||
}}
|
||||
className={`${config.button} font-semibold transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary text-text-primary rounded-lg'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user