diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 5f0889c..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -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" diff --git a/app/components/layout/Section.tsx b/app/components/layout/Section.tsx new file mode 100644 index 0000000..18a6764 --- /dev/null +++ b/app/components/layout/Section.tsx @@ -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 ( +
+

+ {title} +

+ {children} +
+ ); +}; \ No newline at end of file diff --git a/app/components/sections/About.tsx b/app/components/sections/About.tsx new file mode 100644 index 0000000..53b710f --- /dev/null +++ b/app/components/sections/About.tsx @@ -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 ( + +
+
+
+
+ {paragraphs.map((paragraph, i) => { + const Icon = (SOLID as Record)[paragraph.icon]; + return ( + + + + +

+ {t(paragraph.text)} +

+
+ ) + })} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/app/components/sections/Navbar.tsx b/app/components/sections/Navbar.tsx new file mode 100644 index 0000000..25d90e5 --- /dev/null +++ b/app/components/sections/Navbar.tsx @@ -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>; + 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 ( + + {isOpen ? ( + + + + ) : ( + + + + {content.navbar.buttons.slice(1).map((btn, i) => { + const Icon = (SOLID as Record)[btn.icon]; + return ( +
+ + + {t(btn.label)} + +
+ + ); + })} + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/components/sections/Projects.tsx b/app/components/sections/Projects.tsx new file mode 100644 index 0000000..e9c3055 --- /dev/null +++ b/app/components/sections/Projects.tsx @@ -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([]); + + 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 ( + +
+
+ {repos.map((repo, i) => ( + +

{repo.name}

+

+ {repo.description || "No description"} +

+ {repo.language && ( + + {repo.language} + + )} +
+ ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/app/components/sections/Skills.tsx b/app/components/sections/Skills.tsx new file mode 100644 index 0000000..1c8386f --- /dev/null +++ b/app/components/sections/Skills.tsx @@ -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 ( + +
+ {skillsData.map((section, i) => ( +
+
+

{t(section.title)}:

+
+ {section.tags.map((tag, j) => { + const Icon = (SiIcons as Record)[tag.icon]; + return ( + +
+ {Icon && } + {tag.name} +
+
+ ); + })} +
+
+
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/app/components/sections/Title.tsx b/app/components/sections/Title.tsx new file mode 100644 index 0000000..4b9d212 --- /dev/null +++ b/app/components/sections/Title.tsx @@ -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 ( + +
+ + +

{t("me.title")}

+

{t("me.subTitle")}

+
+ + + {content.name} + + +
+
+ ); +}; \ No newline at end of file diff --git a/app/components/three/Three.tsx b/app/components/three/Three.tsx new file mode 100644 index 0000000..9865807 --- /dev/null +++ b/app/components/three/Three.tsx @@ -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(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 ( +
+ {window.WebGL2RenderingContext ? + <> +
+ {loading && ( +
+ )} + + : +
+ WebGL2 is not supported. +
+ } +
+ ); +}; \ No newline at end of file diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx new file mode 100644 index 0000000..344a9d8 --- /dev/null +++ b/app/components/ui/Button.tsx @@ -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 = { + 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 ( +
+ + {label && ( + + {label} + + )} +
+ ); +} \ No newline at end of file diff --git a/app/components/ui/LangSwitcher.tsx b/app/components/ui/LangSwitcher.tsx new file mode 100644 index 0000000..dd66ff3 --- /dev/null +++ b/app/components/ui/LangSwitcher.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/app/components/ui/Window.tsx b/app/components/ui/Window.tsx new file mode 100644 index 0000000..b43d8ee --- /dev/null +++ b/app/components/ui/Window.tsx @@ -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 ( + + {open && ( + +
+ + {children} + +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/app/data/assets.json b/app/data/assets.json new file mode 100644 index 0000000..b87c057 --- /dev/null +++ b/app/data/assets.json @@ -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" + ] +} \ No newline at end of file diff --git a/app/data/content.json b/app/data/content.json new file mode 100644 index 0000000..bd45fbb --- /dev/null +++ b/app/data/content.json @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index a2dc41e..76c05f3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -22,5 +22,10 @@ body { background: var(--background); 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; */ } diff --git a/app/lib/i18n.ts b/app/lib/i18n.ts new file mode 100644 index 0000000..5ae67a6 --- /dev/null +++ b/app/lib/i18n.ts @@ -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; \ No newline at end of file diff --git a/app/locales/en.json b/app/locales/en.json new file mode 100644 index 0000000..a2ca662 --- /dev/null +++ b/app/locales/en.json @@ -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": [ + "", + "", + "", + "" + ] + } +} \ No newline at end of file diff --git a/app/locales/fr.json b/app/locales/fr.json new file mode 100644 index 0000000..8b3f2b4 --- /dev/null +++ b/app/locales/fr.json @@ -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": [ + "", + "", + "", + "" + ] + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..111339f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,49 @@ -import Image from "next/image"; +"use client"; -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+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: () =>
+}); + +export default function App() { + const [state, setState] = useState({ + 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); + + return ( +
+ + + <Navbar state={state} setState={setState} isOpen={isOpen} /> + <About id="about" open={state.about} onClose={() => closeSection("about")} /> + <Skills id="skills" open={state.skills} onClose={() => closeSection("skills")} /> + <Projects id="projects" open={state.projects} onClose={() => closeSection("projects")} /> </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> - ); -} + ); +} \ No newline at end of file diff --git a/app/three/animQueue.ts b/app/three/animQueue.ts new file mode 100644 index 0000000..1cc57b6 --- /dev/null +++ b/app/three/animQueue.ts @@ -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(); + } +} \ No newline at end of file diff --git a/app/three/animation.ts b/app/three/animation.ts new file mode 100644 index 0000000..52b17b6 --- /dev/null +++ b/app/three/animation.ts @@ -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 + } +} \ No newline at end of file diff --git a/app/three/camera.ts b/app/three/camera.ts new file mode 100644 index 0000000..71d1258 --- /dev/null +++ b/app/three/camera.ts @@ -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; + } +} \ No newline at end of file diff --git a/app/three/main.ts b/app/three/main.ts new file mode 100644 index 0000000..6a87c39 --- /dev/null +++ b/app/three/main.ts @@ -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); + } +} \ No newline at end of file diff --git a/app/three/model.ts b/app/three/model.ts new file mode 100644 index 0000000..d5d9a9e --- /dev/null +++ b/app/three/model.ts @@ -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 + ); + }); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bc494dd..4980a87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "website", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.2.0", + "@types/three": "^0.183.1", + "framer-motion": "^12.38.0", "next": "16.2.1", - "react": "19.2.4", - "react-dom": "19.2.4" + "react": "^19.2.4", + "react-dom": "19.2.4", + "react-i18next": "^16.6.1", + "react-icons": "^5.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -19,7 +24,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.1", - "tailwindcss": "^4", + "tailwindcss": "^4.2.2", + "three": "^0.183.2", "typescript": "^5" } }, @@ -228,6 +234,15 @@ "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": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -276,6 +291,12 @@ "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": { "version": "1.9.1", "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_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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1585,6 +1615,12 @@ "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": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1647,6 +1683,33 @@ "@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": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -2235,6 +2298,12 @@ "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": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3637,6 +3706,12 @@ "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": { "version": "8.0.0", "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" } }, + "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3836,9 +3938,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4022,6 +4124,47 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5007,6 +5150,12 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5044,6 +5193,21 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5562,6 +5726,42 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6202,6 +6402,13 @@ "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": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6403,7 +6610,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6539,6 +6746,24 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 8d4062b..9891139 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,14 @@ "lint": "eslint" }, "dependencies": { + "@heroicons/react": "^2.2.0", + "@types/three": "^0.183.1", + "framer-motion": "^12.38.0", "next": "16.2.1", - "react": "19.2.4", - "react-dom": "19.2.4" + "react": "^19.2.4", + "react-dom": "19.2.4", + "react-i18next": "^16.6.1", + "react-icons": "^5.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -20,7 +25,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.1", - "tailwindcss": "^4", + "tailwindcss": "^4.2.2", + "three": "^0.183.2", "typescript": "^5" } } diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..0b7ac7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@app/*": ["./app/*"] } }, "include": [