diff --git a/.gitignore b/.gitignore index 5ef6a52..96fc0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# IDE +/.idea/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..b9ebe07 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,93 @@ +# Déploiement — francktwl.fr + +Le site est un **export statique** Next.js : que du HTML/CSS/JS/images. +**Aucun Node, aucun process à faire tourner sur le serveur.** + +## 1. Construire + +```bash +npm ci +npm run build +``` + +→ génère le dossier **`out/`** (~1,4 Mo). C'est **tout ce qu'il faut transférer**. + +## 2. Transférer sur le serveur + +```bash +rsync -avz --delete out/ user@serveur:/srv/francktwl.fr/ +``` + +(ou `scp -r out/* user@serveur:/srv/francktwl.fr/`) + +Contenu de `out/` : + +``` +index.html → page d'accueil +cv/index.html → /cv +404.html → page d'erreur +robots.txt +sitemap.xml +opengraph-image.png → carte de partage réseaux sociaux +twitter-image.png +icon.png → favicon +pp.jpg → photo +_next/ → JS/CSS (fichiers hashés, cache 1 an) +``` + +## 3. Servir le dossier + +### Caddy + +```caddy +francktwl.fr { + root * /srv/francktwl.fr + encode zstd gzip + try_files {path} {path}/index.html {path}.html + file_server + handle_errors { + rewrite * /404.html + file_server + } + @assets path /_next/static/* + header @assets Cache-Control "public, max-age=31536000, immutable" +} +``` + +### Nginx + +```nginx +server { + listen 80; + server_name francktwl.fr www.francktwl.fr; + root /srv/francktwl.fr; + + location / { + try_files $uri $uri.html $uri/index.html $uri/ =404; + } + + error_page 404 /404.html; + + location /_next/static/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } +} +``` + +TLS : certbot, ou via ton reverse proxy existant (celui qui sert déjà Forgejo / +Nextcloud) — ajoute simplement `francktwl.fr` comme vhost pointant sur +`/srv/francktwl.fr`. + +## 4. À chaque mise à jour + +```bash +npm run build && rsync -avz --delete out/ user@serveur:/srv/francktwl.fr/ +``` + +## Après le premier déploiement + +- Google Search Console : ajouter `francktwl.fr`, prouver la propriété + (coller le token dans `metadata.verification.google` de `app/layout.tsx`, + rebuild, redéployer), soumettre `https://francktwl.fr/sitemap.xml`. +- L'image de partage (`opengraph-image.png` / `twitter-image.png`) est + pré-générée. Pour la changer : éditer le PNG ou regénérer. diff --git a/app/cv/cv-toolbar.tsx b/app/cv/cv-toolbar.tsx new file mode 100644 index 0000000..072f86d --- /dev/null +++ b/app/cv/cv-toolbar.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; + +export default function CvToolbar() { + return ( +
+
+ + ← Portfolio + + +
+
+ ); +} diff --git a/app/cv/page.tsx b/app/cv/page.tsx new file mode 100644 index 0000000..95debb5 --- /dev/null +++ b/app/cv/page.tsx @@ -0,0 +1,287 @@ +import type { Metadata } from "next"; +import CvToolbar from "./cv-toolbar"; +import RevealPhone from "./reveal-phone"; + +export const metadata: Metadata = { + title: "CV", + description: + "CV de Franck Thiaw-Woaye-Lapierre — développeur web & DevOps à La Réunion. Consultable en ligne ou en PDF.", + alternates: { canonical: "/cv" }, +}; + +const experiences = [ + { + role: "Développeur web & administrateur système", + org: "Exhaust Esport — La Réunion", + period: "Juin 2022 → aujourd'hui", + points: [ + "Administration des serveurs web et de jeux (Minecraft, VPN), déployés en conteneurs Docker.", + "Développement et maintenance du site de l'organisation (Laravel, Tailwind CSS).", + ], + }, + { + role: "Stagiaire — datacenter", + org: "Le Tampon — La Réunion", + period: "Août – septembre 2020", + points: [ + "Bases du DevOps et de l'administration système ; installation et gestion de serveurs.", + "Acquisition et configuration de mon premier serveur dédié.", + ], + }, +]; + +const formation = [ + { + title: "Licence Informatique", + org: "Université de La Réunion — Saint-Denis", + period: "2023 → aujourd'hui", + note: "Actuellement en L3, après une L1 et une L2 validées.", + }, + { + title: "Baccalauréat", + org: "Lycée Évariste de Parny — Saint-Paul", + period: "2023", + note: "", + }, +]; + +const projets = [ + { + name: "Exhaust Esport", + desc: "Site officiel de l'équipe esport.", + stack: "Laravel · Tailwind CSS", + url: "https://exhaust.re", + }, + { + name: "Pro Game", + desc: "Site et suivi de réparations pour un atelier de réparation et micro-soudure.", + stack: "Laravel", + url: "https://progame.re", + }, + { + name: "wifi-deauth-test", + desc: "Assistant Tkinter guidant un test de déauthentification Wi-Fi, dans un cadre autorisé (aircrack-ng).", + stack: "Python", + url: "https://forgejo.francktwl.fr/Franck/wifi-deauth-test", + }, + { + name: "Rixguard", + desc: "Site d'un service VPN (projet archivé).", + stack: "Laravel", + url: "", + }, +]; + +const competences = [ + { label: "Langages", value: "PHP, Python, JavaScript, HTML, CSS, Ruby" }, + { + label: "Frameworks", + value: "Laravel, React, Next.js, Node.js, Symfony, Tailwind CSS, Sass", + }, + { + label: "Infrastructure", + value: "Linux, Docker, reverse proxy, auto-hébergement, Git", + }, + { + label: "Systèmes", + value: "Serveurs web et de jeux, VPN, DNS, administration système", + }, +]; + +const contact = [ + { label: "Email", value: "francktwl@francktwl.fr", href: "mailto:francktwl@francktwl.fr" }, + { label: "Téléphone", value: "", href: "" }, + { label: "Localisation", value: "Saint-Paul, La Réunion" }, + { label: "Site", value: "francktwl.fr", href: "https://francktwl.fr" }, + { + label: "Code", + value: "forgejo.francktwl.fr/Franck", + href: "https://forgejo.francktwl.fr/Franck", + }, +]; + +function SectionTitle({ children }: { children: string }) { + return ( +

+ {children} +

+ ); +} + +export default function CvPage() { + return ( +
+ + +
+
+
+

+ Franck Thiaw-Woaye-Lapierre +

+

+ Développeur & DevOps +

+
+
+ +
+ + +
+
+ Profil +

+ Étudiant en L3 informatique à l'Université de La Réunion, + passionné d'informatique depuis le collège. Compétences en + développement web, administration de serveurs et auto-hébergement. + Objectif : travailler dans le DevOps et l'infrastructure ; + l'électronique est un domaine que j'aimerais explorer + par la suite. +

+
+ +
+ Expérience +
+ {experiences.map((e) => ( +
+
+

{e.role}

+ + {e.period} + +
+

+ {e.org} +

+
    + {e.points.map((p) => ( +
  • +
  • + ))} +
+
+ ))} +
+
+ +
+ Formation +
+ {formation.map((f) => ( +
+
+

{f.title}

+ + {f.period} + +
+

+ {f.org} + {f.note ? ` — ${f.note}` : ""} +

+
+ ))} +
+
+ +
+ Projets +
    + {projets.map((p) => ( +
  • + {p.url ? ( + + {p.name} + + ) : ( + {p.name} + )} + — {p.desc} + ({p.stack}) +
  • + ))} +
+
+
+
+
+ + +
+ ); +} diff --git a/app/cv/reveal-phone.tsx b/app/cv/reveal-phone.tsx new file mode 100644 index 0000000..0358c70 --- /dev/null +++ b/app/cv/reveal-phone.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState, useSyncExternalStore } from "react"; + +// Numéro inversé : jamais présent tel quel dans le HTML rendu côté serveur, +// et pas de correspondance grep directe. Il n'est reconstruit et injecté dans +// le DOM qu'après hydratation (client), pour l'impression du CV et le clic +// « Afficher ». Les harvesters qui n'exécutent pas de JS ne récupèrent rien. +const REV = "78 45 06 396 262+"; +const subscribe = () => () => {}; + +export default function RevealPhone() { + const isClient = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + const [shown, setShown] = useState(false); + + if (!isClient) { + return Afficher le numéro; + } + + const display = REV.split("").reverse().join(""); + const tel = display.replace(/\s/g, ""); + const link = ( + + {display} + + ); + + return ( + <> + {/* Visible uniquement à l'impression / export PDF */} + {link} + + {shown ? ( + link + ) : ( + + )} + + + ); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..e885a25 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Link from "next/link"; +import gsap from "gsap"; + +export default function RouteError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + const root = useRef(null); + + useEffect(() => { + console.error(error); + }, [error]); + + useEffect(() => { + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) return; + + const ctx = gsap.context(() => { + gsap.set("[data-fade]", { autoAlpha: 0, y: 14 }); + + gsap + .timeline({ defaults: { ease: "power3.out" } }) + .from("[data-icon]", { autoAlpha: 0, scale: 0.6, duration: 0.4, ease: "back.out(2)" }) + .to("[data-icon]", { + keyframes: { rotate: [0, -9, 8, -6, 4, 0] }, + duration: 0.55, + ease: "power1.inOut", + }) + .to("[data-fade]", { autoAlpha: 1, y: 0, duration: 0.5, stagger: 0.08 }, "-=0.3") + .to("[data-icon]", { + scale: 1.05, + duration: 1.6, + ease: "sine.inOut", + repeat: -1, + yoyo: true, + }); + }, root); + + return () => ctx.revert(); + }, []); + + return ( +
+ + +
+

Quelque chose a planté

+

+ Une erreur inattendue s'est produite pendant le rendu de cette + page. +

+
+ +
+ + + Retour à l'accueil + +
+ + {error.digest && ( +

+ ref: {error.digest} +

+ )} +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..c775f6e --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import gsap from "gsap"; +import "./globals.css"; + +export default function GlobalError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + const root = useRef(null); + + useEffect(() => { + console.error(error); + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) return; + + const ctx = gsap.context(() => { + gsap.from("[data-fade]", { + autoAlpha: 0, + y: 16, + duration: 0.5, + ease: "power3.out", + stagger: 0.09, + }); + }, root); + + return () => ctx.revert(); + }, [error]); + + return ( + + Erreur — Franck Thiaw-Woaye-Lapierre + +
+
+ 500 +
+
+

Erreur critique

+

+ L'application n'a pas pu se charger. Réessaie dans un + instant. +

+
+ +
+ + + ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..2e72072 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,83 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@theme { + --color-canvas: #0d1117; + --color-surface: #161b22; + --color-line: #30363d; + --color-line-soft: #21262d; + --color-fg: #e6edf3; + --color-muted: #8b949e; + --color-accent: #58a6ff; + --color-success: #3fb950; + --color-danger: #f85149; -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", + Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: + radial-gradient( + 620px 460px at -8% -12%, + rgba(56, 132, 255, 0.22), + transparent 58% + ), + radial-gradient( + 580px 460px at 108% -8%, + rgba(150, 100, 255, 0.18), + transparent 58% + ), + radial-gradient( + 900px 560px at 50% 118%, + rgba(22, 200, 165, 0.12), + transparent 62% + ), + #0a0e17; + background-attachment: fixed; + color: var(--color-fg); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} + +/* Panneaux « liquid glass » — verre dépoli translucide */ +@utility glass { + background: rgba(20, 25, 38, 0.62); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 16px 44px -18px rgba(0, 0, 0, 0.7); +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: 6px; +} + +@media print { + .no-print { + display: none !important; + } + :root { + --color-canvas: #ffffff; + --color-surface: #ffffff; + --color-line: #d0d7de; + --color-line-soft: #e4e8ec; + --color-fg: #14161a; + --color-muted: #48505b; + --color-accent: #0b5cad; + --color-success: #1a7f37; + } + body { + background: #fff; + color: #14161a; + } + a { + text-decoration: none; + } } diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..b8b6683 Binary files /dev/null and b/app/icon.png differ diff --git a/app/intro.tsx b/app/intro.tsx new file mode 100644 index 0000000..63537d2 --- /dev/null +++ b/app/intro.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import gsap from "gsap"; + +export default function Intro() { + const pathname = usePathname(); + + useEffect(() => { + const overlay = "[data-intro-overlay]"; + const reveal = gsap.utils.toArray("[data-reveal]"); + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // No reveal targets (e.g. error / not-found pages) or reduced motion: skip the intro. + if (reduce || reveal.length === 0) { + gsap.set(overlay, { display: "none" }); + return; + } + + gsap.set(reveal, { autoAlpha: 0, y: 12 }); + + const tl = gsap.timeline({ + defaults: { ease: "power3.out" }, + onComplete: () => gsap.set(reveal, { clearProps: "all" }), + }); + + tl.to("[data-intro-mark]", { autoAlpha: 1, scale: 1, duration: 0.45, ease: "power2.out" }, 0) + .fromTo( + "[data-intro-bar]", + { scaleX: 0 }, + { scaleX: 1, duration: 0.85, ease: "power2.inOut" }, + 0.1, + ) + .to(overlay, { yPercent: -100, duration: 0.5, ease: "power3.inOut" }, ">-0.1") + .to(reveal, { autoAlpha: 1, y: 0, duration: 0.5, stagger: 0.07 }, "<0.2") + .set(overlay, { display: "none" }); + + return () => { + tl.kill(); + gsap.set(reveal, { clearProps: "all" }); + }; + }, []); + + if (pathname !== "/") return null; + + return ( +