feat: add stress
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m16s

This commit is contained in:
Nathan Lamy 2025-12-23 15:15:11 +01:00
parent db1a7bca57
commit fbb9158684
8 changed files with 848 additions and 0 deletions

View file

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View file

@ -0,0 +1,180 @@
"use client";
import * as React from "react";
import { motion, type HTMLMotionProps } from "motion/react";
import { useIsInView, type UseIsInViewOptions } from "~/lib/use-is-in-view";
import { getStrictContext } from "~/lib/get-strict-context";
type TypingTextContextType = {
isTyping: boolean;
setIsTyping: (isTyping: boolean) => void;
};
const [TypingTextProvider, useTypingText] =
getStrictContext<TypingTextContextType>("TypingTextContext");
type TypingTextProps = React.ComponentProps<"span"> & {
duration?: number;
delay?: number;
loop?: boolean;
holdDelay?: number;
text: string | string[];
} & UseIsInViewOptions;
function TypingText({
ref,
children,
duration = 100,
delay = 0,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
loop = false,
holdDelay = 1000,
text,
...props
}: TypingTextProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
}
);
const [isTyping, setIsTyping] = React.useState(false);
const [started, setStarted] = React.useState(false);
const [displayedText, setDisplayedText] = React.useState<string>("");
React.useEffect(() => {
if (isInView) {
const timeoutId = setTimeout(() => {
setStarted(true);
}, delay);
return () => clearTimeout(timeoutId);
}
}, [isInView, delay]);
React.useEffect(() => {
if (!started) return;
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
const texts: string[] = typeof text === "string" ? [text] : text;
const typeText = (str: string, onComplete: () => void) => {
setIsTyping(true);
let currentIndex = 0;
const type = () => {
if (currentIndex <= str.length) {
setDisplayedText(str.substring(0, currentIndex));
currentIndex++;
const id = setTimeout(type, duration);
timeoutIds.push(id);
} else {
setIsTyping(false);
onComplete();
}
};
type();
};
const eraseText = (str: string, onComplete: () => void) => {
setIsTyping(true);
let currentIndex = str.length;
const erase = () => {
if (currentIndex >= 0) {
setDisplayedText(str.substring(0, currentIndex));
currentIndex--;
const id = setTimeout(erase, duration);
timeoutIds.push(id);
} else {
setIsTyping(false);
onComplete();
}
};
erase();
};
const animateTexts = (index: number) => {
typeText(texts[index] ?? "", () => {
const isLast = index === texts.length - 1;
if (isLast && !loop) {
return;
}
const id = setTimeout(() => {
eraseText(texts[index] ?? "", () => {
const nextIndex = isLast ? 0 : index + 1;
animateTexts(nextIndex);
});
}, holdDelay);
timeoutIds.push(id);
});
};
animateTexts(0);
return () => {
timeoutIds.forEach(clearTimeout);
};
}, [text, duration, started, loop, holdDelay]);
return (
<TypingTextProvider value={{ isTyping, setIsTyping }}>
<span ref={localRef} data-slot="typing-text" {...props}>
<motion.span>{displayedText}</motion.span>
{children}
</span>
</TypingTextProvider>
);
}
type TypingTextCursorProps = Omit<HTMLMotionProps<"span">, "children">;
function TypingTextCursor({
style,
variants,
...props
}: TypingTextCursorProps) {
const { isTyping } = useTypingText();
return (
<motion.span
data-slot="typing-text-cursor"
variants={{
blinking: {
opacity: [0, 0, 1, 1],
transition: {
duration: 1,
repeat: Infinity,
repeatDelay: 0,
ease: "linear",
times: [0, 0.5, 0.5, 1],
},
},
visible: {
opacity: 1,
},
...variants,
}}
animate={isTyping ? "visible" : "blinking"}
style={{
display: "inline-block",
height: "16px",
transform: "translateY(2px)",
width: "1px",
backgroundColor: "currentColor",
...style,
}}
{...props}
/>
);
}
export {
TypingText,
TypingTextCursor,
type TypingTextProps,
type TypingTextCursorProps,
};

View file

@ -0,0 +1,36 @@
import * as React from "react";
function getStrictContext<T>(
name?: string
): readonly [
({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => React.JSX.Element,
() => T
] {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? "a Provider"}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
export { getStrictContext };

View file

@ -0,0 +1,25 @@
import * as React from "react";
import { useInView, type UseInViewOptions } from "motion/react";
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions["margin"];
}
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {}
) {
const { inView, inViewOnce = false, inViewMargin = "0px" } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
export { useIsInView, type UseIsInViewOptions };

View file

@ -9,4 +9,5 @@ export default [
route("/settings", "routes/settings.tsx"), route("/settings", "routes/settings.tsx"),
route("/grades", "routes/grades.tsx"), route("/grades", "routes/grades.tsx"),
route("/repas", "routes/repas.tsx"), route("/repas", "routes/repas.tsx"),
route("/stress", "routes/stress.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

435
app/routes/stress.tsx Normal file
View file

@ -0,0 +1,435 @@
"use client";
import { useEffect, useState } from "react";
import { Info } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { TypingText, TypingTextCursor } from "~/components/ui/text-typing";
const ENCOURAGING_MESSAGES = [
// --- Les Classiques (Gardés) ---
"Tu es plus fort(e) que tu ne le penses. Continue comme ça ! 💪",
"Chaque jour est un pas de plus vers ton objectif. Bravo ! 🌟",
"Ta persévérance est admirable. Tu vas réussir ! 🎯",
"N'oublie pas de te reposer, le sommeil fixe la mémoire ! 😌",
"Les meilleurs résultats viennent avec le temps. Patience ! ⏰",
"Tu fais un travail incroyable. Continue ainsi ! 🚀",
"Crois en toi, tu as tout pour réussir ! ✨",
"Chaque exercice résolu te rapproche de ton rêve ! 📚",
"Ton travail acharné paiera, garde confiance ! 🌈",
"Tu es sur la bonne voie. Reste concentré(e) ! 🎓",
"Prends soin de toi, ta santé mentale est ta meilleure arme ! 💙",
"Ta détermination est inspirante ! 🔥",
"Les concours ne sont qu'une étape, tu es déjà quelqu'un de formidable ! 🌸",
"N'aie pas peur des erreurs, c'est là que tu apprends le plus ! 📝",
"Tu progresses chaque jour, même les jours où tu as l'impression de reculer ! 🌱",
"Ta famille et tes amis sont fiers de toi ! 💖",
"Le meilleur est à venir, l'école de tes rêves t'attend ! 🌅",
"Chaque difficulté surmontée te rend plus fort(e) ! 💎",
"Tu as choisi un chemin difficile, sois fier(e) de le parcourir ! 🏔️",
"N'oublie pas pourquoi tu as commencé. Tu vas y arriver ! 🎪",
// --- Les Nouveaux (Spécial Prépa) ---
"C'est un marathon, pas un sprint. Respire. 🏃",
"Même les major de promo doutent. Toi, tu avances. 🛡️",
"Une mauvaise note en khôlle ne définit pas ta valeur. Relève-toi. 🦋",
"Rappelle-toi la sensation de comprendre enfin une démo complexe. Cherche ça ! 💡",
"Tu es en train de te forger une capacité de travail pour la vie. 🧠",
"L'effort d'aujourd'hui est le sourire de toi-même en juillet. ☀️",
"Fais-le pour le 'toi' qui recevra son affectation SIGEM. 📩",
"La douleur de la discipline pèse moins lourd que celle du regret. ⚖️",
"Tu n'as pas besoin d'être un génie, juste d'être constant. 🐢",
"Visualise ton nom sur la liste des admis. Ça arrive. 👁️",
"Ce chapitre qui te résiste finira par céder. Insiste. 🔨",
"Tu n'es pas seul(e), des milliers d'autres galèrent aussi. Courage ! 🤝",
"Prends 5 minutes pour t'aérer, tu seras plus efficace après. 🌳",
"La prépa, c'est l'école de l'humilité et de la résilience. Tu gères. 🧘",
"Chaque théorème appris est une munition pour le jour J. 🔫",
"Ne te compare pas aux autres, compare-toi à toi hier. 📈",
"Tu construis ton avenir, brique par brique, DM par DM. 🏗️",
"C'est normal d'être fatigué(e), tu accomplis quelque chose de grand. 🛌",
"Garde la tête froide et le cœur chaud. ❤️",
"Il n'y a pas de question bête, il n'y a que des points à gagner. 🙋",
"Le déclic arrive souvent quand on s'y attend le moins. ⚡",
"Sois bienveillant(e) avec toi-même, tu es ton propre moteur. 🚗",
"Regarde tout le chemin parcouru depuis le début de l'année ! 🗺️",
"Tu es capable de comprendre des choses que 99% des gens ignorent. 🌌",
"L'intégration n'est pas une question de chance, c'est une question de ténacité. 🍀",
"Buvard ou Major, l'important c'est de tout donner. 🦁",
"Ton cerveau est un muscle, et là, tu fais de la muscu de haut niveau. 🏋️",
"Rien n'est joué jusqu'à la dernière épreuve. Fonce ! 🏁",
"La réussite, c'est tomber 7 fois et se relever 8. 🩹",
"Fais confiance à ta préparation. Tu as bossé pour ça. 🧱",
"Un jour, tu riras de cette période avec tes potes d'école. 🍻",
"Tu n'étudies pas pour le prof, tu étudies pour ta liberté future. 🕊️",
"Accroche-toi, la vue est superbe depuis le sommet. ⛰️",
"Ta capacité à encaisser la charge de travail est impressionnante. 🔋",
"Ne laisse pas un DS raté gâcher ta semaine. Reset. 🔄",
"Tu es l'architecte de ta propre réussite. 📐",
"Chaque minute investie maintenant te rapportera des années de confort. 🏦",
"Tu as le droit de craquer, mais tu as le devoir de revenir. 🥊",
"Pense à la fierté de porter le tricorne, le képi ou l'uniforme. 🧢",
"La prépa t'apprend à réfléchir, personne ne pourra t'enlever ça. 🗝️",
"C'est dur ? C'est que tu es en train de monter de niveau. 🆙",
"Garde le cap, la tempête finit toujours par passer. ⛵",
"Tu es bien plus intelligent(e) que tu ne le crois sous stress. 🧠",
"Fais-toi un bon thé/café et remets-toi en selle. ☕",
"Le succès est la somme de petits efforts répétés jour après jour. ",
"Ne lâche rien, l'admissibilité se joue parfois à des détails. 🔍",
"Tu as le potentiel pour intégrer l'école qui te correspond. 🧩",
"Respire par le ventre. Tout va bien se passer. 🌬️",
"Ta volonté est ton plus grand talent. ⭐",
"Allez, encore un petit effort. Tu es un(e) guerrier(e). ⚔️",
];
const STRESSFUL_MESSAGES = [
// --- Les Classiques (Gardés) ---
"Pendant que tu scroll, tes concurrents bossent les intégrales. 📉",
"Netflix ou les concours ? Choisis vite. ⏳",
"Les places à l'X ne se gagnent pas sur TikTok. 📱",
"Ton futur toi te regarde avec déception là. 👀",
"Les autres sont déjà au chapitre suivant... 📖",
"Ce DM ne va pas se faire tout seul. Allez hop ! 📝",
"Chaque minute perdue est une place de perdue au classement. ⚠️",
"Tu veux vraiment passer 5/2 ? Bouge-toi ! 🏃",
"Tes notes ne vont pas s'améliorer par magie. 📊",
"La médiocrité est confortable, mais l'X c'est mieux. 🤔",
"Tes concurrents ne prennent pas de pause, eux. ⚡",
"Le niveau monte, et toi tu stagnes. Réagis ! 📈",
"À ce rythme, tu vas finir à la fac (non je rigole... ou pas). 🔄",
"Les colles arrivent, es-tu prêt(e) ? Spoiler : non. 😬",
"Tu sais combien il reste de jours ? C'est terrifiant, non ? ⏰",
"Pendant que tu lis ce message, 3 exos auraient pu être pliés. 🤷",
"La motivation c'est pour les faibles, la discipline c'est pour les forts. 💼",
"Tu te rappelles pourquoi tu es en prépa ? Prouve-le ! 🎯",
"Les profs ont raison : tu gâches ton potentiel là. 📚",
"C'est pas avec des 'demain je m'y mets' que tu vas intégrer Centrale. 🚫",
// --- Les Nouveaux (Spécial "Violence") ---
"Tu vises l'ENS ou l'E3A là ? Ton attitude me met le doute. 🤨",
"Ton major de promo a déjà fini le sujet de 2018, lui. 🏎️",
"C'est pas en regardant le plafond que le théorème va rentrer. 🧱",
"Si tu ne bosses pas maintenant, prépare ton dossier Parcoursup. 📂",
"Dors ou travaille. Tout le reste est une perte de temps. 💤",
"Arrête de te mentir, tu n'es pas en 'pause', tu procrastines. 🤥",
"L'examinateur s'en fiche que tu sois fatigué. Il veut le résultat. 👹",
"Tu crois que Cauchy a procrastiné pour ses suites ? Non. 🔢",
"Ta mention au bac ne te sauvera pas aux concours. 🔥",
"La 5/2, c'est bien, mais intégrer en 3/2 c'est mieux. 🦄",
"Imagine ta tête devant le sujet si tu continues comme ça. 😱",
"Ton téléphone est ton pire ennemi. Éteins-le. 📵",
"Moins de story, plus de théorie. 📸",
"Tu veux finir ingénieur ou touriste ? 👷",
"Les Mines de Paris ne recrutent pas sur Candy Crush. 🍬",
"C'est le moment de souffrir pour ne pas pleurer en juillet. 😭",
"Tu connais tes formules de trigo ? J'en doute. Vérifie. 📐",
"Le jury ne fera pas de cadeau. Pourquoi tu t'en fais ? 🎁",
"Tu as vu le sujet de l'an dernier ? T'es pas prêt. 📉",
"Arrête de rêver ta vie, va étudier ta physique. ⚛️",
"La chimie orga ne s'apprend pas par osmose. 🧪",
"Chaque heure de perdue est une victoire pour ton concurrent direct. 🤺",
"C'est le moment de passer en mode machine. 🤖",
"Tu te reposeras après les écrits. Pour l'instant, au charbon. ⛏️",
"Si c'était facile, tout le monde serait à Polytechnique. 🏰",
"Ton excuse pour ne pas bosser est nulle. Retourne travailler. 🚮",
"L'avenir appartient à ceux qui se lèvent tôt (et qui font des maths). 🌅",
"Tu préfères la douleur de l'effort ou la douleur de l'échec ? 🎭",
"C'est pas le moment de flancher, c'est le money time ! 💰",
"Regarde tes notes de la dernière khôlle. Ça te suffit ? 📉",
"Tu n'es pas fatigué, tu es juste démotivé. Change ça. 🔋",
"La chance ne sourit qu'aux esprits bien préparés. 🍀",
"Tu veux voir tes amis intégrer pendant que tu redoubles ? 👯",
"Fais-le pour humilier ceux qui n'ont pas cru en toi. 😈",
"Pas de bras, pas de chocolat. Pas de travail, pas d'école. 🍫",
"Ton avenir se joue dans les 30 prochaines minutes. ⏳",
"L'excellence est une habitude, pas un acte isolé. 🏆",
"Arrête de chouiner et va faire un DL. 🔢",
"Tu as déjà oublié ton cours d'hier ? C'est grave. 🧠",
"Le concours blanc approche. La panique aussi. 🚨",
"Tu penses que tu as le niveau ? Prouve-le sur une feuille blanche. 📄",
"C'est pas le moment de faire du social. C'est la guerre. ⚔️",
"Tu veux une médaille ? Va la chercher avec tes dents. 🏅",
"La prépa c'est la jungle. Mange ou sois mangé. 🦁",
"Ton lit est confortable, mais le chômage l'est moins. 🛋️",
"Si tu lis ça, c'est que tu ne bosses pas. Allez, oust ! 👉",
"Tu as 5 minutes pour t'y mettre, sinon c'est mort pour aujourd'hui. ⏲️",
"Ne sois pas celui qui dit 'j'aurais dû'. Sois celui qui l'a fait. ✅",
"La physique quantique t'attend. Elle n'est pas patiente. 🌌",
"Arrête de scroller, ça ne va pas augmenter ton admissibilité. 🛑",
];
export default function StressPrepaPage() {
const [timeLeft, setTimeLeft] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
});
const [progress, setProgress] = useState(0);
const [messageMode, setMessageMode] = useState<
"encouraging" | "stressful" | "none"
>("none");
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const examDate = new Date("2026-04-13T07:00:00");
const schoolYearStart = new Date("2025-09-01T00:00:00");
const storedMode = localStorage.getItem("stress-prepa-message-mode");
if (
storedMode === "encouraging" ||
storedMode === "stressful" ||
storedMode === "none"
) {
setMessageMode(storedMode);
}
const updateCountdown = () => {
const now = new Date();
const difference = examDate.getTime() - now.getTime();
if (difference > 0) {
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / 1000 / 60) % 60);
const seconds = Math.floor((difference / 1000) % 60);
setTimeLeft({ days, hours, minutes, seconds });
// Calculate progress percentage
const totalDuration = examDate.getTime() - schoolYearStart.getTime();
const elapsed = now.getTime() - schoolYearStart.getTime();
const progressPercent = Math.min(
Math.max((elapsed / totalDuration) * 100, 0),
100
);
setProgress(progressPercent);
} else {
setTimeLeft({ days: 0, hours: 0, minutes: 0, seconds: 0 });
setProgress(100);
}
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (messageMode === "none") {
setMessages([]);
return;
}
const messages =
messageMode === "encouraging" ? ENCOURAGING_MESSAGES : STRESSFUL_MESSAGES;
setMessages(messages);
}, [messageMode]);
const formatNumber = (num: number) => num.toString().padStart(2, "0");
return (
<div className="relative min-h-full flex flex-col items-center justify-center bg-background px-4 overflow-hidden">
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-muted/20 to-background animate-gradient" />
{/* Enhanced grid pattern */}
<div className="absolute inset-0 opacity-[0.03]">
<div
className="absolute inset-0"
style={{
backgroundImage: `
linear-gradient(to right, currentColor 1px, transparent 1px),
linear-gradient(to bottom, currentColor 1px, transparent 1px)
`,
backgroundSize: "64px 64px",
}}
/>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
className="absolute z-100 top-4 right-4 rounded-full bg-card/80 backdrop-blur-sm hover:bg-card border-border/50 shadow-lg"
>
<Info className="h-4 w-4" />
<span className="sr-only">Informations</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Stress Prépa
</DialogTitle>
<DialogContent className="space-y-4 pt-4 text-base">
<div className="space-y-2">
<p className="text-foreground/90">
Compte à rebours jusqu'aux concours
</p>
<div className="h-px bg-border/50" />
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
Début des concours
</span>
<span className="font-mono font-semibold text-foreground">
13 Avril 2026
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
Début de l'année
</span>
<span className="font-mono font-semibold text-foreground">
1er Sept. 2025
</span>
</div>
</div>
<div className="pt-4 space-y-4">
<div className="h-px bg-border/50" />
<div className="space-y-3">
<Label className="text-sm font-semibold text-foreground">
Mode des messages
</Label>
<RadioGroup
value={messageMode}
onValueChange={(value) => {
localStorage.setItem("stress-prepa-message-mode", value);
setMessageMode(
value as "encouraging" | "stressful" | "none"
);
}}
className="space-y-0"
>
<div className="flex items-center space-x-3 px-3 pt-3 rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="none" id="none" />
<Label
htmlFor="none"
className="font-normal cursor-pointer flex-1"
>
Aucun message 🤐
</Label>
</div>
<div className="flex items-center space-x-3 px-3 py-1 rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="encouraging" id="encouraging" />
<Label
htmlFor="encouraging"
className="font-normal cursor-pointer flex-1"
>
Encouragement 🙌
</Label>
</div>
<div className="flex items-center space-x-3 px-3 rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="stressful" id="stressful" />
<Label
htmlFor="stressful"
className="font-normal cursor-pointer flex-1"
>
Trash talk 🤡
</Label>
</div>
</RadioGroup>
</div>
</div>
<div className="pt-4">
<div className="h-px bg-border/50 mb-4" />
<p className="text-xs text-muted-foreground">
Tous droits réservés Khollisé © {new Date().getFullYear()}
Messages générés avec l'intelligence artificielle.
</p>
</div>
</DialogContent>
</DialogHeader>
</DialogContent>
</Dialog>
<div className="relative w-full max-w-6xl mx-auto text-center space-y-16">
{/* Enhanced title section */}
<div className="space-y-3">
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-balance leading-tight">
Temps avant les concours
</h1>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
<div className="group bg-card border-2 border-border/50 rounded-2xl p-6 md:p-10 transition-all hover:border-border hover:shadow-xl hover:scale-[1.02]">
<div className="text-6xl md:text-8xl font-bold tabular-nums tracking-tight leading-none">
{timeLeft.days}
</div>
<div className="text-xs md:text-sm text-muted-foreground mt-3 font-medium uppercase tracking-wider">
Jour{timeLeft.days > 1 ? "s" : ""}
</div>
</div>
<div className="group bg-card border-2 border-border/50 rounded-2xl p-6 md:p-10 transition-all hover:border-border hover:shadow-xl hover:scale-[1.02]">
<div className="text-6xl md:text-8xl font-bold tabular-nums tracking-tight leading-none">
{formatNumber(timeLeft.hours)}
</div>
<div className="text-xs md:text-sm text-muted-foreground mt-3 font-medium uppercase tracking-wider">
Heure{timeLeft.hours > 1 ? "s" : ""}
</div>
</div>
<div className="group bg-card border-2 border-border/50 rounded-2xl p-6 md:p-10 transition-all hover:border-border hover:shadow-xl hover:scale-[1.02]">
<div className="text-6xl md:text-8xl font-bold tabular-nums tracking-tight leading-none">
{formatNumber(timeLeft.minutes)}
</div>
<div className="text-xs md:text-sm text-muted-foreground mt-3 font-medium uppercase tracking-wider">
Minute{timeLeft.minutes > 1 ? "s" : ""}
</div>
</div>
<div className="group bg-card border-2 border-border/50 rounded-2xl p-6 md:p-10 transition-all hover:border-border hover:shadow-xl hover:scale-[1.02]">
<div className="text-6xl md:text-8xl font-bold tabular-nums tracking-tight leading-none">
{formatNumber(timeLeft.seconds)}
</div>
<div className="text-xs md:text-sm text-muted-foreground mt-3 font-medium uppercase tracking-wider">
Seconde{timeLeft.seconds > 1 ? "s" : ""}
</div>
</div>
</div>
<div className="min-h-10 flex items-center justify-center px-4">
{messages.length > 0 && (
<TypingText
text={messages}
duration={25}
holdDelay={5000}
className="text-lg md:text-xl text-muted-foreground/90 italic max-w-3xl leading-relaxed"
>
<TypingTextCursor className="!h-6 !w-1 rounded-full ml-1 -mb-1" />
</TypingText>
)}
</div>
<div className="space-y-4 max-w-3xl mx-auto">
<div className="relative w-full h-4 bg-muted/50 rounded-full overflow-hidden border border-border/30 shadow-inner">
<div
className="absolute inset-y-0 left-0 bg-primary rounded-full transition-all duration-1000 ease-out shadow-lg"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Progression de l'année
</span>
<span className="font-mono font-bold text-lg text-foreground tabular-nums">
{progress.toFixed(2)}%
</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -20,6 +20,7 @@
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -41,6 +42,7 @@
"katex": "^0.16.22", "katex": "^0.16.22",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"luxon": "^3.7.1", "luxon": "^3.7.1",
"motion": "^12.23.26",
"netlify-cli": "^22.1.3", "netlify-cli": "^22.1.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-day-picker": "^9.8.1", "react-day-picker": "^9.8.1",

124
pnpm-lock.yaml generated
View file

@ -38,6 +38,9 @@ importers:
'@radix-ui/react-popover': '@radix-ui/react-popover':
specifier: ^1.1.14 specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.2.5 specifier: ^2.2.5
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -101,6 +104,9 @@ importers:
luxon: luxon:
specifier: ^3.7.1 specifier: ^3.7.1
version: 3.7.1 version: 3.7.1
motion:
specifier: ^12.23.26
version: 12.23.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
netlify-cli: netlify-cli:
specifier: ^22.1.3 specifier: ^22.1.3
version: 22.4.0(@types/node@20.19.9)(idb-keyval@6.2.2)(picomatch@4.0.3)(rollup@2.79.2) version: 22.4.0(@types/node@20.19.9)(idb-keyval@6.2.2)(picomatch@4.0.3)(rollup@2.79.2)
@ -1965,6 +1971,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-radio-group@1.3.8':
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.10': '@radix-ui/react-roving-focus@1.1.10':
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
peerDependencies: peerDependencies:
@ -1978,6 +1997,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.5': '@radix-ui/react-select@2.2.5':
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==} resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
peerDependencies: peerDependencies:
@ -3871,6 +3903,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
framer-motion@12.23.26:
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@0.5.2: fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -4876,6 +4922,26 @@ packages:
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
motion@12.23.26:
resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
move-file@3.1.0: move-file@3.1.0:
resolution: {integrity: sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==} resolution: {integrity: sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -8694,6 +8760,24 @@ snapshots:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8) '@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.2 '@radix-ui/primitive': 1.1.2
@ -8711,6 +8795,23 @@ snapshots:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8) '@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': '@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1
@ -10755,6 +10856,15 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
framer-motion@12.23.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
motion-dom: 12.23.23
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
fresh@0.5.2: {} fresh@0.5.2: {}
from2@2.3.0: from2@2.3.0:
@ -11740,6 +11850,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
motion@12.23.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
framer-motion: 12.23.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
move-file@3.1.0: move-file@3.1.0:
dependencies: dependencies:
path-exists: 5.0.0 path-exists: 5.0.0