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:
natreex
2026-04-05 12:52:54 -04:00
parent 2b6d4cc48b
commit d4765e6576
49 changed files with 3115 additions and 2 deletions

123
components/ui/AlertBox.tsx Normal file
View 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);
}

View 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);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
)
}

View 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
View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
);
}

View 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>
);
}

View 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>
);
}