Enhance UI components and animations: Introduce a shimmer animation effect in globals.css, refactor FeedbackPageClient, LoginPage, RegisterPage, and AdminPanel components to utilize new UI components for improved consistency and maintainability. Update event and feedback handling in EventsPageSection and FeedbackModal, ensuring a cohesive user experience across the application.

This commit is contained in:
Julien Froidefond
2025-12-12 16:44:57 +01:00
parent db01c25de7
commit 99a475736b
32 changed files with 2242 additions and 1389 deletions

32
components/ui/Alert.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
interface AlertProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
variant?: "success" | "error" | "warning" | "info";
}
const variantClasses = {
success: "bg-green-900/50 border-green-500/50 text-green-400",
error: "bg-red-900/50 border-red-500/50 text-red-400",
warning: "bg-yellow-900/50 border-yellow-500/50 text-yellow-400",
info: "bg-blue-900/50 border-blue-500/50 text-blue-400",
};
export default function Alert({
children,
variant = "info",
className = "",
...props
}: AlertProps) {
return (
<div
className={`border rounded px-4 py-3 text-sm ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</div>
);
}

70
components/ui/Avatar.tsx Normal file
View File

@@ -0,0 +1,70 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { normalizeAvatarUrl } from "@/lib/avatars";
interface AvatarProps {
src: string | null | undefined;
username: string;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
className?: string;
borderClassName?: string;
fallbackText?: string;
}
const sizeClasses = {
xs: "w-6 h-6 text-[8px]",
sm: "w-8 h-8 text-[10px]",
md: "w-10 h-10 text-xs",
lg: "w-16 h-16 sm:w-20 sm:h-20 text-xl sm:text-2xl",
xl: "w-24 h-24 text-4xl",
"2xl": "w-32 h-32 text-4xl",
};
export default function Avatar({
src,
username,
size = "md",
className = "",
borderClassName = "",
fallbackText,
}: AvatarProps) {
const [avatarError, setAvatarError] = useState(false);
const prevSrcRef = useRef<string | null | undefined>(undefined);
// Reset error state when src changes
useEffect(() => {
if (src !== prevSrcRef.current) {
prevSrcRef.current = src;
// Reset error when src changes to allow retry
// eslint-disable-next-line react-hooks/set-state-in-effect
setAvatarError(false);
}
}, [src]);
const sizeClass = sizeClasses[size];
const normalizedSrc = normalizeAvatarUrl(src);
const displaySrc = normalizedSrc && !avatarError ? normalizedSrc : null;
const initial = fallbackText || username.charAt(0).toUpperCase();
return (
<div
className={`${sizeClass} rounded-full border overflow-hidden bg-black/60 flex items-center justify-center relative ${className} ${borderClassName}`}
>
{displaySrc ? (
<img
key={displaySrc}
src={displaySrc}
alt={username}
className="w-full h-full object-cover absolute inset-0"
onError={() => setAvatarError(true)}
/>
) : null}
<span
className={`text-pixel-gold font-bold ${displaySrc ? "hidden" : ""}`}
>
{initial}
</span>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
interface BackgroundSectionProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
backgroundImage: string;
overlay?: boolean;
}
export default function BackgroundSection({
children,
backgroundImage,
overlay = true,
className = "",
...props
}: BackgroundSectionProps) {
return (
<section
className={`relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16 ${className}`}
{...props}
>
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('${backgroundImage}')`,
}}
>
{/* Dark overlay for readability */}
{overlay && (
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
)}
</div>
{/* Content */}
<div className="relative z-10 w-full max-w-6xl mx-auto px-4 sm:px-8 py-16">
{children}
</div>
</section>
);
}

40
components/ui/Badge.tsx Normal file
View File

@@ -0,0 +1,40 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
variant?: "default" | "success" | "warning" | "danger" | "info";
size?: "sm" | "md";
}
const variantClasses = {
default: "bg-pixel-gold/20 border-pixel-gold/50 text-pixel-gold",
success: "bg-green-900/50 border-green-500/50 text-green-400",
warning: "bg-yellow-900/50 border-yellow-500/50 text-yellow-400",
danger: "bg-red-900/50 border-red-500/50 text-red-400",
info: "bg-blue-900/30 border-blue-500/50 text-blue-400",
};
const sizeClasses = {
sm: "px-2 py-1 text-[10px] sm:text-xs",
md: "px-3 py-1 text-xs",
};
export default function Badge({
children,
variant = "default",
size = "sm",
className = "",
...props
}: BadgeProps) {
return (
<span
className={`inline-block border uppercase rounded whitespace-nowrap flex-shrink-0 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
>
{children}
</span>
);
}

47
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { ButtonHTMLAttributes, ReactNode, ElementType } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "success" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
children: ReactNode;
as?: ElementType;
}
const variantClasses = {
primary:
"border-pixel-gold/50 bg-black/60 text-white hover:bg-pixel-gold/10 hover:border-pixel-gold",
secondary:
"border-gray-600/50 bg-gray-900/20 text-gray-400 hover:bg-gray-900/30 hover:border-gray-500",
success:
"border-green-500/50 bg-green-900/20 text-green-400 hover:bg-green-900/30",
danger: "border-red-500/50 bg-red-900/20 text-red-400 hover:bg-red-900/30",
ghost: "border-transparent bg-transparent text-white hover:text-pixel-gold",
};
const sizeClasses = {
sm: "px-2 sm:px-3 py-1 text-[10px] sm:text-xs",
md: "px-4 py-2 text-xs",
lg: "px-6 py-3 text-sm",
};
export default function Button({
variant = "primary",
size = "md",
className = "",
disabled,
children,
as: Component = "button",
...props
}: ButtonProps) {
return (
<Component
className={`border uppercase tracking-widest rounded transition disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</Component>
);
}

30
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,30 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
variant?: "default" | "dark";
}
const variantClasses = {
default: "bg-black/60 border border-pixel-gold/30",
dark: "bg-black/80 border border-pixel-gold/30",
};
export default function Card({
children,
variant = "default",
className = "",
...props
}: CardProps) {
return (
<div
className={`rounded-lg backdrop-blur-sm ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { ButtonHTMLAttributes } from "react";
interface CloseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "text-xl",
md: "text-2xl",
lg: "text-3xl",
};
export default function CloseButton({
size = "md",
className = "",
...props
}: CloseButtonProps) {
return (
<button
className={`text-gray-400 hover:text-pixel-gold font-bold transition disabled:opacity-50 disabled:cursor-not-allowed ${sizeClasses[size]} ${className}`}
{...props}
>
×
</button>
);
}

38
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,38 @@
"use client";
import { InputHTMLAttributes, forwardRef } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = "", ...props }, ref) => {
return (
<div>
{label && (
<label
htmlFor={props.id}
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
{label}
</label>
)}
<input
ref={ref}
className={`w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition ${className}`}
{...props}
/>
{error && (
<p className="text-red-400 text-xs mt-1">{error}</p>
)}
</div>
);
}
);
Input.displayName = "Input";
export default Input;

54
components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,54 @@
"use client";
import { ReactNode, useEffect } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
closeOnOverlayClick?: boolean;
}
const sizeClasses = {
sm: "max-w-md",
md: "max-w-2xl",
lg: "max-w-3xl",
xl: "max-w-4xl",
};
export default function Modal({
isOpen,
onClose,
children,
size = "md",
closeOnOverlayClick = true,
}: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={closeOnOverlayClick ? onClose : undefined}
>
<div
className={`bg-black border-2 border-pixel-gold/70 rounded-lg w-full ${sizeClasses[size]} max-h-[90vh] overflow-y-auto shadow-2xl`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { HTMLAttributes } from "react";
interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {
value: number;
max: number;
variant?: "hp" | "xp" | "default";
showLabel?: boolean;
label?: string;
}
const variantClasses = {
hp: {
high: "from-green-600 to-green-700",
medium: "from-yellow-600 to-orange-700",
low: "from-red-700 to-red-900",
},
xp: "from-pixel-gold/80 via-pixel-gold/70 to-pixel-gold/80",
default: "from-pixel-gold/80 via-pixel-gold/70 to-pixel-gold/80",
};
export default function ProgressBar({
value,
max,
variant = "default",
showLabel = false,
label,
className = "",
...props
}: ProgressBarProps) {
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
let gradientClass = "";
if (variant === "hp") {
if (percentage > 60) {
gradientClass = variantClasses.hp.high;
} else if (percentage > 30) {
gradientClass = variantClasses.hp.medium;
} else {
gradientClass = variantClasses.hp.low;
}
} else if (variant === "xp") {
gradientClass = variantClasses.xp;
} else {
gradientClass = variantClasses.default;
}
return (
<div className={className} {...props}>
{showLabel && (
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>{label || variant.toUpperCase()}</span>
<span>
{value} / {max}
</span>
</div>
)}
<div className="relative h-2 sm:h-3 bg-gray-900 border border-gray-700 rounded overflow-hidden">
<div
className={`absolute inset-0 bg-gradient-to-r ${gradientClass} transition-all duration-1000 ease-out`}
style={{ width: `${percentage}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
</div>
{variant === "hp" && percentage < 30 && (
<div className="absolute inset-0 border border-red-500 rounded animate-pulse"></div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
interface SectionTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children: ReactNode;
variant?: "default" | "gradient" | "gold";
size?: "sm" | "md" | "lg" | "xl";
subtitle?: ReactNode;
}
const sizeClasses = {
sm: "text-2xl sm:text-3xl",
md: "text-3xl sm:text-4xl md:text-5xl",
lg: "text-4xl sm:text-5xl md:text-6xl lg:text-7xl",
xl: "text-5xl md:text-7xl",
};
export default function SectionTitle({
children,
variant = "default",
size = "md",
subtitle,
className = "",
...props
}: SectionTitleProps) {
const baseClasses = "font-gaming font-black tracking-tight mb-4";
let titleClasses = `${baseClasses} ${sizeClasses[size]} ${className}`;
if (variant === "gradient") {
titleClasses += " bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent";
} else if (variant === "gold") {
titleClasses += " text-pixel-gold";
} else {
titleClasses += " text-white";
}
return (
<div className="text-center">
<h1 className={titleClasses} {...props}>
{variant === "gradient" ? (
<span
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
{children}
</span>
) : (
children
)}
</h1>
{subtitle && (
<div className="text-pixel-gold text-lg md:text-xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 tracking-wide">
<span></span>
<span>{subtitle}</span>
<span></span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
interface StarRatingProps {
value: number;
onChange?: (rating: number) => void;
disabled?: boolean;
size?: "sm" | "md" | "lg";
showValue?: boolean;
}
const sizeClasses = {
sm: "text-lg sm:text-xl",
md: "text-2xl sm:text-3xl",
lg: "text-4xl",
};
export default function StarRating({
value,
onChange,
disabled = false,
size = "md",
showValue = false,
}: StarRatingProps) {
const [hoverValue, setHoverValue] = useState(0);
const handleClick = (rating: number) => {
if (!disabled && onChange) {
onChange(rating);
}
};
const displayValue = hoverValue || value;
return (
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => handleClick(star)}
disabled={disabled}
onMouseEnter={() => !disabled && setHoverValue(star)}
onMouseLeave={() => !disabled && setHoverValue(0)}
className={`transition-transform hover:scale-110 disabled:hover:scale-100 disabled:cursor-not-allowed ${
star <= displayValue
? "text-pixel-gold"
: "text-gray-600 hover:text-gray-500"
} ${sizeClasses[size]}`}
aria-label={`Noter ${star} étoile${star > 1 ? "s" : ""}`}
>
</button>
))}
</div>
{showValue && value > 0 && (
<p className="text-gray-500 text-xs text-center">
{value}/5
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { TextareaHTMLAttributes, forwardRef } from "react";
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
showCharCount?: boolean;
maxLength?: number;
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, showCharCount, maxLength, className = "", value, ...props }, ref) => {
const charCount = typeof value === "string" ? value.length : 0;
return (
<div>
{label && (
<label
htmlFor={props.id}
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
{label}
</label>
)}
<textarea
ref={ref}
maxLength={maxLength}
value={value}
className={`w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition resize-none ${className}`}
{...props}
/>
{showCharCount && maxLength && (
<p className="text-gray-500 text-xs mt-1 text-right">
{charCount}/{maxLength} caractères
</p>
)}
{error && (
<p className="text-red-400 text-xs mt-1">{error}</p>
)}
</div>
);
}
);
Textarea.displayName = "Textarea";
export default Textarea;

14
components/ui/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export { default as Avatar } from "./Avatar";
export { default as Button } from "./Button";
export { default as Input } from "./Input";
export { default as Textarea } from "./Textarea";
export { default as Card } from "./Card";
export { default as Modal } from "./Modal";
export { default as Badge } from "./Badge";
export { default as ProgressBar } from "./ProgressBar";
export { default as StarRating } from "./StarRating";
export { default as SectionTitle } from "./SectionTitle";
export { default as BackgroundSection } from "./BackgroundSection";
export { default as Alert } from "./Alert";
export { default as CloseButton } from "./CloseButton";