feat: rework entire project structure

This commit is contained in:
2026-03-23 00:04:04 +01:00
parent 6282951b88
commit 8185757933
26 changed files with 1414 additions and 86 deletions

View File

@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View File

@@ -0,0 +1,24 @@
import React, { JSX } from "react";
type SectionProps = {
children: JSX.Element;
title: string;
id: string
};
export default function Section({ children, title, id }: SectionProps) {
return (
<section
id={id}
className="my-3 relative max-w-5xl mx-auto mt-5 rounded-2xl flex flex-col gap-8 text-gray-100"
>
<h2
className="text-3xl sm:text-5xl font-extrabold bg-clip-text text-transparent
bg-gradient-to-r from-green-200 via-emerald-600 to-green-800"
>
{title}
</h2>
{children}
</section>
);
};

View File

@@ -0,0 +1,53 @@
import React, { JSX } from "react";
import { motion } from "framer-motion";
import * as SOLID from "@heroicons/react/24/solid";
import { useTranslation } from 'react-i18next';
import Section from "@app/components/layout/Section";
import content from "@app/data/content.json"
import Window from "@app/components/ui/Window";
type AboutProps = {
id: string;
open: boolean;
onClose: () => void;
};
export default function About({ id, open, onClose }: AboutProps) {
const { t } = useTranslation();
const paragraphs = content.about;
return (
<Window open={open} onClose={onClose}>
<div className="flex justify-center items-center h-full w-full md:px-6">
<div className="max-w-3xl w-full bg-black/21 rounded-2xl p-8 space-y-4 shadow-lg">
<Section id={id} title={t('about.title')}>
<div className="flex flex-col gap-1">
{paragraphs.map((paragraph, i) => {
const Icon = (SOLID as Record<string, React.ElementType>)[paragraph.icon];
return (
<motion.div
key={i}
className="flex items-start gap-3 p-4 rounded-xl hover:bg-black/30 transition-colors"
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0 }}
transition={{ duration: 0.6, delay: i * 0.08 }}
>
<span className="mt-1 text-emerald-400">
<Icon className="w-6 h-6" />
</span>
<p className="text-sm md:text-base leading-relaxed text-white">
{t(paragraph.text)}
</p>
</motion.div>
)
})}
</div>
</Section>
</div>
</div>
</Window>
);
};

View File

@@ -0,0 +1,112 @@
import React, { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { motion, AnimatePresence, Variants } from "framer-motion";
import * as SOLID from "@heroicons/react/24/solid";
import Button from "@app/components/ui/Button";
import content from "@app/data/content.json";
import { MenuState } from "@/app/page";
import Lang from "@app/components/ui/LangSwitcher"
type NavbarProps = {
state: MenuState;
setState: Dispatch<SetStateAction<MenuState>>;
isOpen: boolean;
};
const navVariants: Variants = {
initial: { scaleY: 0.8, scaleX: 0.1, opacity: 0.7 },
animate: { scaleY: 1, scaleX: 1, opacity: 1, transition: { duration: 0.3, ease: [0.42, 0, 0.58, 1] } },
};
const exitVariants: Variants = {
initial: { scaleY: 1, scaleX: 5, opacity: 0.7 },
animate: { scaleY: 1, scaleX: 1, opacity: 1, transition: { duration: 0.3, ease: [0.42, 0.66, 0.58, 1] } },
};
export default function Navbar({ state, setState, isOpen }: NavbarProps) {
const { t } = useTranslation();
const handleClick = (key: keyof MenuState) => {
setState(prev => ({ ...prev, [key]: true }));
};
const closeWindows = () => {
setState(prev => {
const newState: MenuState = {} as MenuState;
for (const key in prev) newState[key as keyof MenuState] = false;
return newState;
});
};
const mainButton = content.navbar.buttons[0];
return (
<AnimatePresence>
{isOpen ? (
<motion.div
key="close"
className="fixed bottom-4 left-1/2 transform -translate-x-1/2 flex justify-center z-50"
variants={exitVariants}
initial="initial"
animate="animate"
exit="exit"
>
<Button
onClick={closeWindows}
label="exit"
variant="icon"
>
<SOLID.XMarkIcon className="w-8 h-8 text-white" />
</Button>
</motion.div>
) : (
<motion.div
key="menu"
className="fixed bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 z-50"
variants={navVariants}
initial="initial"
animate="animate"
exit="exit"
>
<Button
onClick={() => handleClick(mainButton.action as keyof MenuState)}
label={t(mainButton.label)}
variant="text"
>
<div className="flex flex-col items-center justify-center whitespace-nowrap">
<span className="text-base md:text-lg font-bold text-white drop-shadow-lg">
{content.name}
</span>
<span className="text-xs md:text-sm text-gray-300 font-light">
{t(content.career)}
</span>
</div>
</Button>
{content.navbar.buttons.slice(1).map((btn, i) => {
const Icon = (SOLID as Record<string, React.ElementType>)[btn.icon];
return (
<div className="relative group" key={i}>
<Button
onClick={() => handleClick(btn.action as keyof MenuState)}
label={t(btn.label)}
variant="icon"
>
{Icon && <Icon className="w-6 h-6 text-white" />}
</Button>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 rounded-md text-xs text-white bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{t(btn.label)}
</span>
</div>
);
})}
<Lang />
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,82 @@
import { motion } from "framer-motion";
import React, { useEffect, useState } from "react";
import Section from "@app/components/layout/Section";
import { useTranslation } from "react-i18next";
import Window from "@app/components/ui/Window";
interface ProjectProps {
name: string;
description: string;
html_url: string;
language: string | null;
}
type ProjectsProps = {
id: string;
open: boolean;
onClose: () => void;
};
export default function Projects({ id, open, onClose }: ProjectsProps) {
const [repos, setRepos] = useState<ProjectProps[]>([]);
useEffect(() => {
const fetchRepos = async () => {
try {
const res = await fetch(`https://api.github.com/users/guezoloic/repos?per_page=100`);
const data = await res.json();
const sorted = data
.filter((repo: any) => !repo.fork)
.sort((a: any, b: any) => b.stargazers_count - a.stargazers_count)
.slice(0, 6)
.map((repo: any) => ({
name: repo.name,
description: repo.description,
html_url: repo.html_url,
language: repo.language,
}));
setRepos(sorted);
} catch (err) {
console.error("Error while loading repos", err);
}
};
fetchRepos();
}, []);
const { t } = useTranslation();
return (
<Window open={open} onClose={onClose}>
<Section id={id} title={t("projects.title")}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{repos.map((repo, i) => (
<motion.a
key={repo.name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="bg-black/30 p-6 rounded-2xl shadow-lg hover:scale-105 transition-transform duration-300 flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.6 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<h3 className="text-xl font-semibold text-white">{repo.name}</h3>
<p className="text-gray-200 text-sm">
{repo.description || "No description"}
</p>
{repo.language && (
<span className="text-sm font-medium text-emerald-400">
{repo.language}
</span>
)}
</motion.a>
))}
</div>
</Section>
</Window>
);
};

View File

@@ -0,0 +1,54 @@
import React from "react";
import { motion } from "framer-motion";
import * as SiIcons from "react-icons/si";
import Section from "@app/components/layout/Section";
import { useTranslation } from "react-i18next";
import content from "@app/data/content.json"
import Window from "@app/components/ui/Window";
type SkillsProps = {
id: string;
open: boolean;
onClose: () => void;
};
export default function Skills({ id, open, onClose }: SkillsProps) {
const { t } = useTranslation();
const skillsData = content.skills;
return (
<Window open={open} onClose={onClose}>
<Section id={id} title={t("skills.title")}>
{skillsData.map((section, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="flex flex-col gap-2 max-w-3xl w-full bg-black/20 rounded-2xl p-8 space-y-4 shadow-lg">
<h2 className="text-xl font-semibold font-extrabold bg-clip-text text-transparent
bg-gradient-to-r from-green-200 via-emerald-600 to-green-800">{t(section.title)}:</h2>
<div className="flex flex-wrap gap-2">
{section.tags.map((tag, j) => {
const Icon = (SiIcons as Record<string, React.ElementType>)[tag.icon];
return (
<motion.div
key={j}
className="flex items-center gap-2 p-2"
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0 }}
transition={{ duration: 0.5, delay: j * 0.08 }}
>
<div className="flex items-center gap-2 p-1.5">
{Icon && <Icon className="w-5 h-5 text-emerald-400 mt-1" />}
<span className="text-sm md:text-base">{tag.name}</span>
</div>
</motion.div>
);
})}
</div>
</div>
</div>
))}
</Section>
</Window>
);
};

View File

@@ -0,0 +1,50 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import content from "@app/data/content.json"
export default function Title({isOpen}: {isOpen: boolean}) {
const { t } = useTranslation();
return (
<motion.section
className="flex items-center justify-center"
style={isOpen ? { overflow: "hidden" } : undefined}
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ amount: 0.2, once: false }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className="absolute top-6 bg-transparent px-4 md:px-12 pt-6 md:pt-5 max-w-5xl flex flex-row justify-between items-center gap-15">
<motion.div
className="text-x2l md:text-3xl font-bold text-white leading-tight"
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 0.3 }}
>
<h3>{t("me.title")}</h3>
<p className="text-sm font-medium text-gray-400">{t("me.subTitle")}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 25 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 0.3 }}
>
<img
src={content.profile_image}
alt={content.name}
loading="lazy"
className="w-auto h-17 md:h-20 rounded-full object-cover shadow-2xl border-4 border-white/20 transform transition-transform duration-500 hover:scale-105 self-start block"
width={96}
height={96}
/>
</motion.div>
</div>
</motion.section>
);
};

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import Main from "@app/three/main";
export default function Three() {
const mountRef = useRef<HTMLDivElement>(null); // Parent canva element
const [loading, setLoading] = useState(true); // Loading boolean element
useEffect(() => {
if (!window.WebGL2RenderingContext) return;
const loadingManager = new THREE.LoadingManager(() => setLoading(false));
new Main(mountRef.current!, loadingManager);
}, []);
// canva must exist before loading so that Main doesn't crash
return (
<div className="fixed w-full h-full inset-0 z-0 overflow-hidden">
{window.WebGL2RenderingContext ?
<>
<div ref={mountRef} className="top-0 left-0 w-full h-full" />
{loading && (
<div className="absolute top-1/2 left-1/2 w-16 h-16 -translate-x-1/2 -translate-y-1/2 border-4 border-white border-t-transparent rounded-full animate-spin" />
)}
</>
:
<div className="text-white text-center mt-10">
WebGL2 is not supported.
</div>
}
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { ReactNode } from "react";
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
label: string;
variant?: "icon" | "text";
className?: string;
};
export default function Button({ children, onClick, label, variant = "icon", className = "" }: ButtonProps) {
const BASECLASS = "cursor-pointer flex items-center justify-center backdrop-blur-sm \
bg-black/17 shadow-md text-white transition-all duration-200 ease-out \
hover:bg-white/15 active:scale-95 shadow-lg shadow-black/50 \
pointer-events-auto hover:shadow-black/0";
// dictionary to choose if it's a icon or text button
const variants: Record<typeof variant, string> = {
icon: "rounded-full w-12 h-12 md:w-14 md:h-14 hover:scale-110",
text: "rounded-3xl px-4 h-12 md:h-14 md:px-6 max-w-max hover:scale-105",
};
return (
<div className="relative group">
<button onClick={onClick} aria-label={label} className={`${BASECLASS} ${variants[variant]}`}>
{children}
</button>
{label && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1
rounded-md text-xs text-white bg-black/80 opacity-0
group-hover:opacity-100 transition-opacity whitespace-nowrap">
{label}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Button from '@app/components/ui/Button';
import i18n from "@app/lib/i18n";
export default function Lang() {
const toggleLanguage = () => {
const newLang = i18n.language === "fr" ? "en" : "fr";
i18n.changeLanguage(newLang);
};
const nextLangLabel = i18n.language === "fr" ? "EN" : "FR";
return (
<Button
onClick={toggleLanguage}
label={`Lang: ${nextLangLabel}`}
variant="icon"
>
{nextLangLabel}
</Button>
)
}

View File

@@ -0,0 +1,43 @@
import React, { ReactNode, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface SectionProps {
open: boolean;
onClose: () => void;
children: ReactNode;
}
export default function Window ({ open, onClose, children }: SectionProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
}, [open]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-10 bg-black/30 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3
}}
>
<div className="h-full overflow-y-auto text-white">
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
transition={{ duration: 0.3 }}
className="flex flex-col md:flex-row items-center justify-center gap-10 px-6 md:px-24 py-20 md:py-32"
>
{children}
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
);
};

13
app/data/assets.json Normal file
View File

@@ -0,0 +1,13 @@
{
"model": "BASEmodel.glb",
"idle_animation": "idle.glb",
"welcome_animation": "waving.glb",
"animations": [
"StandingW_BriefcaseIdle.glb",
"Acknowledging.glb",
"ArmStretching.glb",
"OffensiveIdle.glb",
"ThoughtfulHeadShake.glb",
"DwarfIdle.glb"
]
}

139
app/data/content.json Normal file
View File

@@ -0,0 +1,139 @@
{
"name": "GUEZO Loïc",
"career": "me.career",
"profile_image": "https://web-bucket.s3.fr-par.scw.cloud/guezoloic.png",
"navbar": {
"buttons": [
{
"label": "about.label",
"action": "about",
"icon": ""
},
{
"label": "skills.label",
"action": "skills",
"icon": "CodeBracketIcon"
},
{
"label": "projects.label",
"action": "projects",
"icon": "ServerIcon"
}
]
},
"about": [
{
"icon": "LightBulbIcon",
"text": "about.content.0"
},
{
"icon": "CodeBracketIcon",
"text": "about.content.1"
},
{
"icon": "CpuChipIcon",
"text": "about.content.2"
},
{
"icon": "PuzzlePieceIcon",
"text": "about.content.3"
},
{
"icon": "Cog6ToothIcon",
"text": "about.content.4"
},
{
"icon": "RocketLaunchIcon",
"text": "about.content.5"
},
{
"icon": "ServerStackIcon",
"text": "about.content.6"
}
],
"skills": [
{
"title": "skills.content.0",
"tags": [
{
"name": "C",
"icon": "SiC"
},
{
"name": "C++",
"icon": "SiCplusplus"
},
{
"name": "Rust",
"icon": "SiRust"
},
{
"name": "Python",
"icon": "SiPython"
},
{
"name": "Java",
"icon": "SiJava"
},
{
"name": "Bash",
"icon": "SiGnubash"
},
{
"name": "HTML/CSS/JS/TS",
"icon": "SiHtml5"
},
{
"name": "React",
"icon": "SiReact"
}
]
},
{
"title": "skills.content.1",
"tags": [
{
"name": "Git",
"icon": "SiGit"
},
{
"name": "Docker",
"icon": "SiDocker"
},
{
"name": "Android Studio",
"icon": "SiAndroid"
}
]
},
{
"title": "skills.content.2",
"tags": [
{
"name": "Linux/Linux Server",
"icon": "SiLinux"
},
{
"name": "Proxmox",
"icon": "SiProxmox"
},
{
"name": "Jetson Nano",
"icon": "SiNvidia"
},
{
"name": "Raspberry Pi",
"icon": "SiRaspberrypi"
},
{
"name": "Arduino",
"icon": "SiArduino"
},
{
"name": "ESP32",
"icon": "SiEspressif"
}
]
}
]
}

View File

@@ -22,5 +22,10 @@
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; height: 100dvh;
margin: 0;
padding: 0;
background-color: #1e3d2e;
font-family: 'Inter', sans-serif;
/* font-family: Arial, Helvetica, sans-serif; */
} }

23
app/lib/i18n.ts Normal file
View File

@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from '@app/locales/en.json';
import fr from '@app/locales/fr.json';
const resources = { en: { translation: en }, fr: { translation: fr } };
const userLang = navigator.language.startsWith('fr') ? 'fr' : 'en';
i18n
.use(initReactI18next)
.init({
resources,
lng: userLang,
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
;
// document.documentElement.lang = i18n.language;
export default i18n;

44
app/locales/en.json Normal file
View File

@@ -0,0 +1,44 @@
{
"me": {
"title": "Hello There! 👋",
"subTitle": "I'm glad you're here",
"career": "IT Student"
},
"about": {
"label": "About",
"title": "Hi, I'm Loïc! 👋",
"content": [
"Curious by nature, I like to explore what happens under all the layers of a computer system.",
"I started with simple Python programs, then I gradually discovered a fascination for low-level programming.",
"By learning the basics of Intel x86_64 assembly in my degree and touching electronics, I realized that I love learning and designing at a fundamental level.",
"Over time, I got closer and closer to low-level programming and completed a few small projects.",
"These projects allowed me to manipulate and experiment with the functioning of different hardware.",
"I am also interested in various projects, such as creating a small AI or a graphical engine on a terminal.",
"Computer tools and hardware have given me a complete vision of embedded systems, and I want to continue learning in this field.",
"I aim to combine software and hardware to design complex and efficient systems."
]
},
"skills": {
"label": "Skills",
"title": "My Skills",
"content": [
"Programming Languages",
"Tools",
"Hardware & Embedded Systems"
]
},
"projects": {
"label": "Projects",
"title": "My Projects"
},
"links": {
"label": "Links",
"title": "Links",
"descriptions": [
"",
"",
"",
""
]
}
}

44
app/locales/fr.json Normal file
View File

@@ -0,0 +1,44 @@
{
"me": {
"title": "Bienvenue ! 👋",
"subTitle": "Ravi·e de vous voir ici",
"career": "Étudiant info"
},
"about": {
"label": "À propos",
"title": "Bonjour, je suis Loïc ! 👋",
"content": [
"Curieux de nature, j'aime explorer ce qui se passe sous toutes les couches d'un système informatique.",
"J'ai commencé par de simples programmes en Python, puis j'ai progressivement découvert une fascination pour le bas niveau.",
"En apprenant les bases de l'assembleur Intel x86_64 en licence et en touchant à l'électronique, j'ai compris que j'adore apprendre et concevoir à un niveau fondamental.",
"Au fil du temps, je me suis rapproché de la programmation bas niveau et réalisé quelques petits projets.",
"Ces projets m'ont permis de manipuler et d'expérimenter le fonctionnement de différents matériels.",
"Je m'intéresse aussi à des projets variés, comme créer une petite IA ou un moteur graphique sur terminal.",
"Les outils informatique et matériels m'ont donné une vision complète des systèmes embarqués et je souhaite continuer à apprendre dans cette voie.",
"Je cherche à combiner logiciel et matériel pour concevoir des systèmes complexes et efficaces."
]
},
"skills": {
"label": "Compétences",
"title": "Mes Compétences",
"content": [
"Langages Informatique",
"Outils",
"Matériel & Systèmes embarqués"
]
},
"projects": {
"label": "Projets",
"title": "Mes Projets"
},
"links": {
"label": "Liens",
"title": "Liens",
"descriptions": [
"",
"",
"",
""
]
}
}

View File

@@ -1,65 +1,49 @@
import Image from "next/image"; "use client";
import Title from "@app/components/sections/Title";
import Navbar from "@app/components/sections/Navbar";
import About from "@app/components/sections/About";
import Skills from "@app/components/sections/Skills";
import Projects from "@app/components/sections/Projects";
import dynamic from 'next/dynamic';
import { useState } from "react";
import '@app/lib/i18n';
export type MenuState = {
about: boolean;
skills: boolean;
projects: boolean;
};
const Three = dynamic(() => import('@app/components/three/Three'), {
ssr: false,
loading: () => <div className="fixed inset-0 bg-black" />
});
export default function App() {
const [state, setState] = useState<MenuState>({
about: false,
skills: false,
projects: false,
});
const closeSection = (key: keyof MenuState) => {
setState(prev => ({ ...prev, [key]: false }));
};
const isOpen = Object.values(state).some(value => value === true);
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="relative w-full h-screen">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <Three />
<Image <Title isOpen={isOpen}/>
className="dark:invert" <Navbar state={state} setState={setState} isOpen={isOpen} />
src="/next.svg" <About id="about" open={state.about} onClose={() => closeSection("about")} />
alt="Next.js logo" <Skills id="skills" open={state.skills} onClose={() => closeSection("skills")} />
width={100} <Projects id="projects" open={state.projects} onClose={() => closeSection("projects")} />
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div> </div>
); );
} }

92
app/three/animQueue.ts Normal file
View File

@@ -0,0 +1,92 @@
import * as THREE from "three";
import Animation from "./animation";
import assets from "@app/data/assets.json"
const ANIMATION_ASSETS = assets.animations;
export default class AnimationQueue {
private animation: Animation;
private queue: THREE.AnimationAction[] = [];
private currentAction: THREE.AnimationAction | null = null;
private mixer: THREE.AnimationMixer;
private randomIntervalId: number | null = null;
constructor(animation: Animation) {
this.mixer = animation.getMixer();
this.animation = animation;
}
public onqueue(action: THREE.AnimationAction) {
this.queue.push(action);
this.tryPlayNext();
}
public replaceFirst(action: THREE.AnimationAction) {
this.queue.unshift(action);
this.tryPlayNext(true);
}
public async tryPlayNext(force: boolean = false) {
if (this.currentAction) {
if (force) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
if ((this.currentAction as any).isBasicClone) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
return;
}
}
}
if (!this.queue.length) this.queue.push(this.animation.getBasicAction());
const NEXTACTION = this.queue.shift()!;
NEXTACTION.reset();
NEXTACTION.setLoop(THREE.LoopOnce, 1);
NEXTACTION.clampWhenFinished = true;
NEXTACTION.fadeIn(this.animation.getFadein()).play();
const onFinish = (e: any) => {
if (e.action === this.currentAction) {
if (this.currentAction) this.currentAction.fadeOut(this.animation.getFadeout());
this.mixer.removeEventListener("finished", onFinish);
this.currentAction = null;
return this.tryPlayNext();
}
};
this.currentAction = NEXTACTION;
this.mixer.addEventListener("finished", onFinish);
}
public startRandom() {
if (this.randomIntervalId !== null) return;
this.randomIntervalId = window.setInterval(async () => {
if (!this.mixer) return;
const RANDOMINDEX = Math.floor(Math.random() * ANIMATION_ASSETS.length);
this.onqueue(await this.animation.loadAnimation(ANIMATION_ASSETS[RANDOMINDEX]));
}, 30_000);
}
public stopRandom() {
if (this.randomIntervalId !== null) {
clearInterval(this.randomIntervalId);
this.randomIntervalId = null;
}
}
public clearQueue() {
this.queue = [];
}
public stop() {
this.clearQueue();
this.currentAction?.fadeOut(this.animation.getFadeout());
this.currentAction = null;
this.stopRandom();
}
}

81
app/three/animation.ts Normal file
View File

@@ -0,0 +1,81 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Animation {
private mixer: THREE.AnimationMixer;
private loader: GLTFLoader;
private actions: Map<string, THREE.AnimationAction> = new Map();
private basicAction!: THREE.AnimationAction;
private fadein: number;
private fadeout: number;
constructor(
model: THREE.Object3D,
loader: GLTFLoader,
basicAction_url: string,
fadein: number = 0.5, fadeout: number = 0.8) {
this.mixer = new THREE.AnimationMixer(model);
this.loader = loader;
this.fadein = fadein;
this.fadeout = fadeout;
this.setBasicAction(basicAction_url);
}
public loadAnimation(url: string): Promise<THREE.AnimationAction> {
url = `https://web-bucket.s3.fr-par.scw.cloud/${url}`;
return new Promise((resolve, reject) => {
if (this.actions.has(url)) return resolve(this.actions.get(url)!);
this.loader.load(
url,
(gltf: GLTF) => {
if (!gltf.animations.length) return reject(new Error(`${url} has no animations`));
let clip = gltf.animations[0];
if (clip.tracks.some(track => track.name.endsWith('.position'))) {
clip = clip.clone();
clip.tracks = clip.tracks.filter(track => !track.name.endsWith('.position'));
}
const action = this.mixer.clipAction(clip);
action.stop();
this.actions.set(url, action);
resolve(action);
},
undefined,
reject
);
});
}
public setBasicAction(url: string) {
this.loadAnimation(url).then(action => {
this.basicAction = action;
});
}
public getBasicAction(): THREE.AnimationAction {
const clipClone = this.basicAction.getClip().clone();
const action = this.mixer.clipAction(clipClone);
(action as any).isBasicClone = true;
return action;
}
public getFadein(): number { return this.fadein; }
public getFadeout(): number { return this.fadeout; }
public update(delta: number) {
this.mixer?.update(delta);
}
public getMixer(): THREE.AnimationMixer {
if (!this.mixer) throw new Error("Mixer not initialized yet!");
return this.mixer
}
}

34
app/three/camera.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as THREE from "three";
export default class Camera {
private camera: THREE.PerspectiveCamera;
private box = new THREE.Box3();
private size = new THREE.Vector3();
private center = new THREE.Vector3();
constructor(camera: THREE.PerspectiveCamera, model: THREE.Object3D) {
this.camera = camera;
this.box = this.box.setFromObject(model);
}
public centerCamera(
mouvement: (box: THREE.Box3, size: THREE.Vector3, center: THREE.Vector3) => void
) {
this.box.getSize(this.size);
this.box.getCenter(this.center);
mouvement(this.box, this.size, this.center);
}
public positionCamera() {
const fov = this.camera.fov * (Math.PI / 180);
const distance = this.size.y / (2 * Math.tan(fov / 2));
this.camera.position.set(0, 0, distance * 1.2);
this.camera.lookAt(0, 0, 0);
}
public getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
}

109
app/three/main.ts Normal file
View File

@@ -0,0 +1,109 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Model from "./model";
import Camera from "./camera";
import Animation from "./animation";
import assets from "@app/data/assets.json"
import AnimationQueue from "./animQueue";
export default class Main {
private element!: HTMLElement;
private loadingManager?: THREE.LoadingManager;
private scene!: THREE.Scene;
private renderer!: THREE.WebGLRenderer;
private camera!: THREE.PerspectiveCamera;
private controls!: OrbitControls;
private loader!: GLTFLoader;
private clock: THREE.Clock;
private animation!: Animation;
constructor(htmlelement: HTMLElement, loadingManager?: THREE.LoadingManager) {
this.element = htmlelement;
this.loadingManager = loadingManager;
this.clock = new THREE.Clock();
this.init();
}
private async init() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75, // fov
this.element.clientWidth / this.element.clientHeight, // aspect
0.1, 1000 // near, far
);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this.renderer.setSize(
this.element.clientWidth,
this.element.clientHeight
);
this.element.appendChild(this.renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
this.controls = new OrbitControls(this.camera, this.element);
this.controls.minPolarAngle = Math.PI / 2;
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.enableZoom = false;
this.controls.enablePan = false;
this.renderer.domElement.style.touchAction = 'pan-y';
this.loader = new GLTFLoader(this.loadingManager);
window.addEventListener("resize", () => {
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;
this.camera.aspect = WIDTH / HEIGHT;
this.camera.updateProjectionMatrix();
this.renderer.setSize(WIDTH, HEIGHT);
});
const MODEL = await (new Model(this.loader)).init(assets.model, this.scene);
const CAMERA = new Camera(this.camera, MODEL);
CAMERA.centerCamera((_box: any, size: any, center: any) => {
MODEL.position.sub(center);
MODEL.position.y -= size.y * 0.3;
});
CAMERA.positionCamera();
// Animation
this.animation = new Animation(MODEL, this.loader, assets.idle_animation);
const ANIMATION_QUEUE = new AnimationQueue(this.animation);
ANIMATION_QUEUE.onqueue(await this.animation.loadAnimation(assets.welcome_animation));
ANIMATION_QUEUE.startRandom();
this.animate();
}
// animate must be an arrow key so that "this"
// can be pointed to the Main instance
private animate = () => {
requestAnimationFrame(this.animate);
const delta = this.clock.getDelta();
if (this.animation) this.animation.update(delta);
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}

29
app/three/model.ts Normal file
View File

@@ -0,0 +1,29 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Model {
private loader: GLTFLoader;
constructor(loader: GLTFLoader) {
this.loader = loader;
}
public async init(model_url: string, scene: THREE.Scene, isVisible: boolean = true): Promise<THREE.Object3D> {
const MODEL = await this.loadModel(`https://web-bucket.s3.fr-par.scw.cloud/${model_url}`);
MODEL.visible = isVisible;
scene.add(MODEL);
return MODEL;
}
public loadModel(model_url: string): Promise<THREE.Object3D> {
return new Promise((resolve, reject) => {
this.loader.setCrossOrigin('anonymous');
this.loader.load(
model_url,
(gltf: GLTF) => resolve(gltf.scene),
undefined,
reject
);
});
}
}

239
package-lock.json generated
View File

@@ -8,9 +8,14 @@
"name": "website", "name": "website",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@types/three": "^0.183.1",
"framer-motion": "^12.38.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "^19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-i18next": "^16.6.1",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -19,7 +24,8 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"tailwindcss": "^4", "tailwindcss": "^4.2.2",
"three": "^0.183.2",
"typescript": "^5" "typescript": "^5"
} }
}, },
@@ -228,6 +234,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -276,6 +291,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -453,6 +474,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1585,6 +1615,12 @@
"tailwindcss": "4.2.2" "tailwindcss": "4.2.2"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1647,6 +1683,33 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -2235,6 +2298,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3637,6 +3706,12 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3717,6 +3792,33 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"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
}
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3836,9 +3938,9 @@
} }
}, },
"node_modules/get-tsconfig": { "node_modules/get-tsconfig": {
"version": "4.13.6", "version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4022,6 +4124,47 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.10.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.4.tgz",
"integrity": "sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5007,6 +5150,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -5044,6 +5193,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5562,6 +5726,42 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-i18next": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.1.tgz",
"integrity": "sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-icons": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6202,6 +6402,13 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6403,7 +6610,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -6539,6 +6746,24 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -9,9 +9,14 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@types/three": "^0.183.1",
"framer-motion": "^12.38.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "^19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-i18next": "^16.6.1",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -20,7 +25,8 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"tailwindcss": "^4", "tailwindcss": "^4.2.2",
"three": "^0.183.2",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -19,7 +19,8 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"],
"@app/*": ["./app/*"]
} }
}, },
"include": [ "include": [