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
+
+ window.print()}
+ className="rounded-md border border-line bg-surface px-3.5 py-1.5 text-sm font-semibold hover:border-muted"
+ >
+ Télécharger en PDF
+
+
+
+ );
+}
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
+
+
+
+
+
+
+
+ Contact
+
+ {contact.map((c) => (
+
+
+ {c.label}
+
+ {c.label === "Téléphone" ? (
+
+ ) : c.href ? (
+
+ {c.value}
+
+ ) : (
+ {c.value}
+ )}
+
+ ))}
+
+
+
+
+ Compétences
+
+ {competences.map((c) => (
+
+
+ {c.label}
+
+ {c.value}
+
+ ))}
+
+
+
+
+ Langues
+
+
+ Français
+ — natif
+
+
+ Anglais
+ — TOEIC
+
+
+
+
+
+
+
+ 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) => (
+
+
+ {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
+ ) : (
+ setShown(true)}
+ className="text-accent hover:underline"
+ >
+ Afficher le numéro
+
+ )}
+
+ >
+ );
+}
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.
+
+
+
+
+ retry()}
+ className="rounded-md border border-line bg-surface px-4 py-2 text-sm font-semibold hover:border-muted"
+ >
+ Réessayer
+
+
+ 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.
+
+
+ retry()}
+ className="rounded-md border border-line bg-surface px-4 py-2 text-sm font-semibold hover:border-muted"
+ >
+ Recharger
+
+
+
+
+ );
+}
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 (
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 9852c15..bc0e88f 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,29 +1,86 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { Metadata, Viewport } from "next";
import "./globals.css";
+import Intro from "./intro";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+const SITE = "https://francktwl.fr";
+const NAME = "Franck Thiaw-Woaye-Lapierre";
+const TAGLINE = "Développeur web & DevOps à La Réunion";
+const DESCRIPTION =
+ "Franck Thiaw-Woaye-Lapierre — étudiant en L3 informatique à La Réunion. " +
+ "Développement web (Laravel, React) et DevOps : serveurs Linux, Docker, auto-hébergement.";
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ metadataBase: new URL(SITE),
+ title: {
+ default: `${NAME} — ${TAGLINE}`,
+ template: `%s · ${NAME}`,
+ },
+ description: DESCRIPTION,
+ applicationName: `${NAME} · Portfolio`,
+ authors: [{ name: NAME, url: SITE }],
+ creator: NAME,
+ publisher: NAME,
+ keywords: [
+ NAME,
+ "Franck Thiaw Woaye Lapierre",
+ "francktwl",
+ "développeur La Réunion",
+ "développeur web Réunion",
+ "DevOps La Réunion",
+ "DevOps Réunion 974",
+ "administrateur système Réunion",
+ "développeur Laravel Réunion",
+ "auto-hébergement",
+ "Linux",
+ "Docker",
+ "portfolio développeur Réunion",
+ ],
+ category: "technology",
+ alternates: { canonical: "/" },
+ openGraph: {
+ type: "profile",
+ firstName: "Franck",
+ lastName: "Thiaw-Woaye-Lapierre",
+ username: "francktwl",
+ url: SITE,
+ siteName: `${NAME} — Portfolio`,
+ title: `${NAME} — ${TAGLINE}`,
+ description: DESCRIPTION,
+ locale: "fr_FR",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: `${NAME} — ${TAGLINE}`,
+ description: DESCRIPTION,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ "max-video-preview": -1,
+ },
+ },
+
+};
+
+export const viewport: Viewport = {
+ themeColor: "#0d1117",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
-
- {children}
+
+
+ {children}
+
+
+
+
+
);
}
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..0466969
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import gsap from "gsap";
+
+export default function NotFound() {
+ const root = useRef(null);
+ const pathname = usePathname();
+
+ useEffect(() => {
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (reduce) return;
+
+ const ctx = gsap.context(() => {
+ const digits = gsap.utils.toArray("[data-digit]");
+ gsap.set("[data-fade]", { autoAlpha: 0, y: 14 });
+
+ gsap
+ .timeline({ defaults: { ease: "power3.out" } })
+ .from(digits, {
+ yPercent: -130,
+ rotate: -14,
+ autoAlpha: 0,
+ duration: 0.7,
+ ease: "back.out(1.6)",
+ stagger: 0.12,
+ })
+ .to("[data-fade]", { autoAlpha: 1, y: 0, duration: 0.5, stagger: 0.09 }, "-=0.15")
+ .add(() => {
+ gsap.to(digits, {
+ y: (i: number) => (i % 2 ? 7 : -7),
+ duration: 2.4,
+ ease: "sine.inOut",
+ repeat: -1,
+ yoyo: true,
+ stagger: { each: 0.15, from: "center" },
+ });
+ });
+ }, root);
+
+ return () => ctx.revert();
+ }, []);
+
+ return (
+
+
+ 4
+
+ 0
+
+ 4
+
+
+
+ fatal: le chemin «
+ {pathname}
+ » n'existe pas dans ce dépôt
+
+
+
+
+ ← Retour à l'accueil
+
+
+ Voir les projets
+
+
+
+ );
+}
diff --git a/app/opengraph-image.png b/app/opengraph-image.png
new file mode 100644
index 0000000..239d002
Binary files /dev/null and b/app/opengraph-image.png differ
diff --git a/app/page.tsx b/app/page.tsx
index c887311..e9672da 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,69 +1,404 @@
-import Image from "next/image";
+import Link from "next/link";
+
+const badges = [
+ { alt: "Linux", src: "https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black" },
+ { alt: "Docker", src: "https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" },
+ { alt: "Python", src: "https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white" },
+ { alt: "PHP", src: "https://img.shields.io/badge/PHP-777BB4?style=for-the-badge&logo=php&logoColor=white" },
+ { alt: "Laravel", src: "https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white" },
+ { alt: "JavaScript", src: "https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black" },
+ { alt: "React", src: "https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" },
+ { alt: "Tailwind CSS", src: "https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" },
+ { alt: "Git", src: "https://img.shields.io/badge/Git-F05032?style=for-the-badge&logo=git&logoColor=white" },
+];
+
+type Repo = {
+ name: string;
+ desc: string;
+ lang: string;
+ langColor: string;
+ topics: string[];
+ url?: string;
+ archived?: boolean;
+};
+
+const repos: Repo[] = [
+ {
+ name: "wifi-deauth-test",
+ desc: "Assistant Tkinter qui guide un test de déauthentification Wi-Fi sur un client, dans un cadre autorisé, en s'appuyant sur aircrack-ng.",
+ lang: "Python",
+ langColor: "#3572A5",
+ topics: ["python", "tkinter", "aircrack-ng", "wifi-security"],
+ url: "https://forgejo.francktwl.fr/Franck/wifi-deauth-test",
+ },
+ {
+ name: "exhaust-esport",
+ desc: "Site officiel de l'équipe esport Exhaust.",
+ lang: "PHP",
+ langColor: "#4F5D95",
+ topics: ["laravel", "php", "tailwindcss", "esport"],
+ url: "https://exhaust.re",
+ },
+ {
+ name: "progame",
+ desc: "Site et suivi de réparations pour Pro Game, atelier de réparation et micro-soudure à La Réunion.",
+ lang: "PHP",
+ langColor: "#4F5D95",
+ topics: ["laravel", "php", "suivi-réparation"],
+ url: "https://progame.re",
+ },
+ {
+ name: "rixguard",
+ desc: "Site vitrine et espace client d'un service VPN. Le service a fermé, le site n'existe plus aujourd'hui.",
+ lang: "PHP",
+ langColor: "#4F5D95",
+ topics: ["laravel", "php", "vpn"],
+ archived: true,
+ },
+];
+
+const parcours = [
+ {
+ year: "2023",
+ title: "Licence Informatique — Université de La Réunion",
+ body: "Actuellement en L3, après une L1 et une L2 validées.",
+ },
+ {
+ year: "juin 2022",
+ title: "Développeur web & administrateur système — Exhaust Esport",
+ body: "Serveurs web et de jeux (Minecraft, VPN), site de l'organisation en Laravel, services conteneurisés avec Docker.",
+ },
+ {
+ year: "2022–2023",
+ title: "Magicien — centre de loisirs, Saint-Paul",
+ body: "Animation de spectacles pour enfants pendant les vacances scolaires.",
+ },
+ {
+ year: "2022",
+ title: "Baccalauréat — Lycée Évariste de Parny",
+ body: "Plateau Caillou, La Réunion.",
+ },
+ {
+ year: "2020",
+ title: "Stage — datacenter, Le Tampon",
+ body: "Bases du DevOps et de l'administration système. Premier serveur dédié acquis à cette occasion.",
+ },
+];
+
+const homelab: { icon: string; name: string; note: string; url?: string }[] = [
+ {
+ icon: "🗃️",
+ name: "Forgejo",
+ note: "",
+ url: "https://forgejo.francktwl.fr/Franck",
+ },
+ { icon: "☁️", name: "Nextcloud", note: "" },
+ { icon: "🛡️", name: "AdGuard Home", note: "" },
+ { icon: "🔒", name: "VPN", note: "" },
+ { icon: "🌐", name: "Mes sites web", note: "" },
+];
+
+const personLd = {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: "Franck Thiaw-Woaye-Lapierre",
+ alternateName: "francktwl",
+ url: "https://francktwl.fr",
+ image: "https://francktwl.fr/pp.jpg",
+ jobTitle: "Développeur web & DevOps",
+ description:
+ "Étudiant en L3 informatique à La Réunion, orienté DevOps, administration de serveurs Linux et auto-hébergement.",
+ address: {
+ "@type": "PostalAddress",
+ addressLocality: "Saint-Paul",
+ addressRegion: "La Réunion",
+ addressCountry: "RE",
+ },
+ knowsLanguage: ["fr", "en"],
+ alumniOf: {
+ "@type": "CollegeOrUniversity",
+ name: "Université de La Réunion",
+ },
+ knowsAbout: [
+ "DevOps",
+ "Linux",
+ "Docker",
+ "Laravel",
+ "Auto-hébergement",
+ "Administration système",
+ "Réseau",
+ ],
+ sameAs: [
+ "https://forgejo.francktwl.fr/Franck",
+ "https://www.instagram.com/francktwl_photo/",
+ "https://discordapp.com/users/320512642785607681",
+ ],
+};
export default function Home() {
return (
-
-
-
-
-
- To get started, edit the{" "}
-
- page.tsx
- {" "}
- file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
+ <>
+
+
+
+
+ F
+
+
Franck Thiaw-Woaye-Lapierre
+
- Templates
- {" "}
- or the{" "}
+ git (nouvel onglet)
+
- Learning
- {" "}
- center.
-
+ email
+
+
+ CV
+
+
-
+
+
+
+ 💻 Développeur & DevOps · La Réunion
+
+
+
+
+ Étudiant en L3 informatique. J'auto-héberge mon infra depuis 2020
+ : serveurs Linux, services en Docker, mon propre forge git. J'apprends
+ surtout en cassant des choses pour comprendre comment elles tiennent.
+
+
+
+
+
+
+
+
+ 📖
+ francktwl / README.md
+
+
+
Salut, moi c'est Franck 👋
+
+ Passionné d'informatique depuis le collège — les jeux vidéo
+ d'abord, puis l'assemblage de mon premier PC. Je suis
+ aujourd'hui en 3ᵉ année de licence informatique à La Réunion.
+
+
+ Ce qui me plaît vraiment, c'est gérer des machines :
+ administrer des serveurs Linux, les configurer, faire tourner mes
+ services dans Docker. Mon objectif est de travailler dans le
+ DevOps ; l'électronique est un domaine que j'aimerais
+ explorer plus tard.
+
+
+ {badges.map((b) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ))}
+
+
+
+
+
+ Homelab
+
+
+ J'héberge mes propres services derrière un reverse proxy, sur
+ un serveur dédié en ligne depuis 2020 — tout en conteneurs.
+
+
+
+
+
+
+ Épinglé
+
+ {repos.map((r) => (
+
+
+ {r.desc}
+
+ {r.topics.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+ {r.lang}
+
+
+ ))}
+
+
+
+
+ Parcours
+
+ {parcours.map((p) => (
+
+
+ {p.year}
+ {p.title}
+ {p.body}
+
+ ))}
+
+
+
+
+ >
);
}
diff --git a/app/robots.ts b/app/robots.ts
new file mode 100644
index 0000000..847e2ce
--- /dev/null
+++ b/app/robots.ts
@@ -0,0 +1,61 @@
+import type { MetadataRoute } from "next";
+
+export const dynamic = "force-static";
+
+// Moteurs de recherche + robots d'aperçu de lien (pour que le référencement
+// et les cartes Open Graph continuent de fonctionner quand tu partages le site).
+const ALLOWED = [
+ "Googlebot",
+ "Google-InspectionTool",
+ "Bingbot",
+ "DuckDuckBot",
+ "facebookexternalhit",
+ "Twitterbot",
+ "LinkedInBot",
+ "Slackbot-LinkExpanding",
+ "Discordbot",
+ "TelegramBot",
+ "WhatsApp",
+];
+
+// Robots d'entraînement IA et scrapers connus : bloqués explicitement.
+const BLOCKED = [
+ "GPTBot",
+ "ChatGPT-User",
+ "OAI-SearchBot",
+ "ClaudeBot",
+ "Claude-Web",
+ "anthropic-ai",
+ "Google-Extended",
+ "PerplexityBot",
+ "CCBot",
+ "Bytespider",
+ "Amazonbot",
+ "Applebot-Extended",
+ "Meta-ExternalAgent",
+ "FacebookBot",
+ "cohere-ai",
+ "Diffbot",
+ "Omgilibot",
+ "ImagesiftBot",
+ "YouBot",
+ "AhrefsBot",
+ "SemrushBot",
+ "MJ12bot",
+ "DotBot",
+ "DataForSeoBot",
+ "Scrapy",
+];
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [
+ ...ALLOWED.map((userAgent) => ({ userAgent, allow: "/" })),
+ ...BLOCKED.map((userAgent) => ({ userAgent, disallow: "/" })),
+ // Tout le reste : interdit.
+ { userAgent: "*", disallow: "/" },
+ ],
+ sitemap: "https://francktwl.fr/sitemap.xml",
+ host: "https://francktwl.fr",
+ };
+}
diff --git a/app/sitemap.ts b/app/sitemap.ts
new file mode 100644
index 0000000..3dcfa94
--- /dev/null
+++ b/app/sitemap.ts
@@ -0,0 +1,20 @@
+import type { MetadataRoute } from "next";
+
+export const dynamic = "force-static";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [
+ {
+ url: "https://francktwl.fr",
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 1,
+ },
+ {
+ url: "https://francktwl.fr/cv",
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.8,
+ },
+ ];
+}
diff --git a/app/twitter-image.png b/app/twitter-image.png
new file mode 100644
index 0000000..239d002
Binary files /dev/null and b/app/twitter-image.png differ
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..f67eb8f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "export",
+ trailingSlash: true,
+ images: { unoptimized: true },
+ devIndicators: false,
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index fa0b54f..bd319fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "francktwl",
"version": "0.1.0",
"dependencies": {
+ "gsap": "^3.15.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
@@ -4064,6 +4065,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/gsap": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
diff --git a/package.json b/package.json
index 35fe515..ea58d96 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
+ "gsap": "^3.15.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/pp.jpg b/public/pp.jpg
new file mode 100644
index 0000000..1e72037
Binary files /dev/null and b/public/pp.jpg differ
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file