Refonte du portfolio : profil GitHub, CV, SEO, export statique
- Accueil façon profil GitHub (bio, homelab, projets épinglés, parcours) avec fond dégradé et panneaux liquid glass - /cv : consultable en ligne (thème sombre), imprimable en PDF propre (thème clair, 1 page) ; numéro de téléphone non scrapable - Pages d'erreur 404 / 500 / global-error animées (GSAP) - Métadonnées : Open Graph, Twitter, JSON-LD Person, sitemap, robots (allowlist Google + bots d'aperçu, reste bloqué) - Images OG et favicon en PNG statiques - Contenu du CV : Saint-Paul, langues, expériences détaillées - Export statique (output: export) + DEPLOY.md - Retrait de l'indicateur de dev Next.js et des fichiers du starter Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3hzMzvei419MwkKFZkvMX
3
.gitignore
vendored
|
|
@ -39,3 +39,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
/.idea/
|
||||
|
|
|
|||
93
DEPLOY.md
Normal file
|
|
@ -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.
|
||||
25
app/cv/cv-toolbar.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CvToolbar() {
|
||||
return (
|
||||
<div className="no-print sticky top-0 z-10 border-b border-line bg-canvas/85 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-[860px] items-center justify-between gap-3 px-6 py-2.5">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium text-muted hover:text-accent"
|
||||
>
|
||||
← Portfolio
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
app/cv/page.tsx
Normal file
|
|
@ -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 (
|
||||
<h2 className="mb-1.5 border-b border-line pb-1 text-[14px] font-semibold">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CvPage() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-canvas text-fg print:min-h-0">
|
||||
<CvToolbar />
|
||||
|
||||
<article className="print:m-[12mm]">
|
||||
<header className="border-b border-line">
|
||||
<div className="mx-auto max-w-[860px] px-6 py-6 sm:px-8 print:px-0">
|
||||
<h1 className="text-[26px] font-semibold tracking-tight sm:text-[32px] sm:leading-[1.12]">
|
||||
Franck Thiaw-Woaye-Lapierre
|
||||
</h1>
|
||||
<p className="mt-1 text-base font-medium text-accent">
|
||||
Développeur & DevOps
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto grid max-w-[860px] gap-x-10 gap-y-5 px-6 pt-5 pb-8 text-[13px] leading-[1.5] sm:grid-cols-[200px_minmax(0,1fr)] sm:px-8 print:px-0">
|
||||
<aside className="space-y-4 text-[12px]">
|
||||
<section className="break-inside-avoid">
|
||||
<SectionTitle>Contact</SectionTitle>
|
||||
<ul className="space-y-1.5">
|
||||
{contact.map((c) => (
|
||||
<li key={c.label}>
|
||||
<span className="block text-[11px] font-semibold tracking-wide text-muted uppercase">
|
||||
{c.label}
|
||||
</span>
|
||||
{c.label === "Téléphone" ? (
|
||||
<RevealPhone />
|
||||
) : c.href ? (
|
||||
<a
|
||||
href={c.href}
|
||||
className="break-words text-accent hover:underline"
|
||||
target={c.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
c.href.startsWith("http")
|
||||
? "noreferrer noopener"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{c.value}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="break-inside-avoid">
|
||||
<SectionTitle>Compétences</SectionTitle>
|
||||
<ul className="space-y-1.5">
|
||||
{competences.map((c) => (
|
||||
<li key={c.label}>
|
||||
<span className="block text-[11px] font-semibold tracking-wide text-muted uppercase">
|
||||
{c.label}
|
||||
</span>
|
||||
<span className="text-fg/85">{c.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="break-inside-avoid">
|
||||
<SectionTitle>Langues</SectionTitle>
|
||||
<ul className="space-y-1.5">
|
||||
<li>
|
||||
<span className="font-semibold">Français</span>
|
||||
<span className="text-muted"> — natif</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold">Anglais</span>
|
||||
<span className="text-muted"> — TOEIC</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main className="space-y-4">
|
||||
<section>
|
||||
<SectionTitle>Profil</SectionTitle>
|
||||
<p className="max-w-[64ch] text-fg/90">
|
||||
É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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Expérience</SectionTitle>
|
||||
<div className="space-y-2.5">
|
||||
{experiences.map((e) => (
|
||||
<div key={e.role} className="break-inside-avoid">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
|
||||
<h3 className="text-[14px] font-semibold">{e.role}</h3>
|
||||
<span className="text-[11px] whitespace-nowrap text-muted tabular-nums">
|
||||
{e.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12.5px] font-medium text-accent">
|
||||
{e.org}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5 text-fg/85">
|
||||
{e.points.map((p) => (
|
||||
<li key={p} className="flex gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-[7px] size-1 shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Formation</SectionTitle>
|
||||
<div className="space-y-2.5">
|
||||
{formation.map((f) => (
|
||||
<div key={f.title} className="break-inside-avoid">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
|
||||
<h3 className="text-[14px] font-semibold">{f.title}</h3>
|
||||
<span className="text-[11px] whitespace-nowrap text-muted tabular-nums">
|
||||
{f.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12.5px] font-medium text-accent">
|
||||
{f.org}
|
||||
{f.note ? ` — ${f.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Projets</SectionTitle>
|
||||
<ul className="space-y-1.5">
|
||||
{projets.map((p) => (
|
||||
<li key={p.name} className="break-inside-avoid">
|
||||
{p.url ? (
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[13px] font-semibold text-accent hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold">{p.name}</span>
|
||||
)}
|
||||
<span className="text-fg/85"> — {p.desc}</span>
|
||||
<span className="text-[11px] text-muted"> ({p.stack})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>{`
|
||||
@media print {
|
||||
@page { size: A4; margin: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/cv/reveal-phone.tsx
Normal file
|
|
@ -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 <span className="text-muted">Afficher le numéro</span>;
|
||||
}
|
||||
|
||||
const display = REV.split("").reverse().join("");
|
||||
const tel = display.replace(/\s/g, "");
|
||||
const link = (
|
||||
<a href={`tel:${tel}`} className="text-accent hover:underline">
|
||||
{display}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Visible uniquement à l'impression / export PDF */}
|
||||
<span className="hidden print:inline">{link}</span>
|
||||
<span className="print:hidden">
|
||||
{shown ? (
|
||||
link
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShown(true)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Afficher le numéro
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
app/error.tsx
Normal file
|
|
@ -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<HTMLElement>(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 (
|
||||
<main
|
||||
ref={root}
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center"
|
||||
>
|
||||
<svg
|
||||
data-icon
|
||||
viewBox="0 0 24 24"
|
||||
className="size-20 text-danger"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
|
||||
<div data-fade className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Quelque chose a planté</h1>
|
||||
<p className="max-w-[46ch] text-muted">
|
||||
Une erreur inattendue s'est produite pendant le rendu de cette
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-fade className="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retry()}
|
||||
className="rounded-md border border-line bg-surface px-4 py-2 text-sm font-semibold hover:border-muted"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md border border-line px-4 py-2 text-sm font-semibold text-accent hover:border-muted"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error.digest && (
|
||||
<p data-fade className="font-mono text-xs text-muted">
|
||||
ref: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB |
64
app/global-error.tsx
Normal file
|
|
@ -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<HTMLBodyElement>(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 (
|
||||
<html lang="fr">
|
||||
<title>Erreur — Franck Thiaw-Woaye-Lapierre</title>
|
||||
<body ref={root} className="min-h-screen antialiased">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<div
|
||||
data-fade
|
||||
className="leading-none font-bold text-danger [font-size:clamp(4rem,18vw,9rem)]"
|
||||
>
|
||||
500
|
||||
</div>
|
||||
<div data-fade className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Erreur critique</h1>
|
||||
<p className="max-w-[46ch] text-muted">
|
||||
L'application n'a pas pu se charger. Réessaie dans un
|
||||
instant.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-fade
|
||||
type="button"
|
||||
onClick={() => retry()}
|
||||
className="rounded-md border border-line bg-surface px-4 py-2 text-sm font-semibold hover:border-muted"
|
||||
>
|
||||
Recharger
|
||||
</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
app/icon.png
Normal file
|
After Width: | Height: | Size: 384 B |
67
app/intro.tsx
Normal file
|
|
@ -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<HTMLElement>("[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 (
|
||||
<div
|
||||
data-intro-overlay
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-canvas"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
data-intro-bar
|
||||
className="absolute inset-x-0 top-0 h-[3px] origin-left bg-accent"
|
||||
style={{ transform: "scaleX(0)" }}
|
||||
/>
|
||||
<div
|
||||
data-intro-mark
|
||||
className="grid size-12 place-items-center rounded-xl bg-surface text-lg font-bold"
|
||||
style={{ opacity: 0, transform: "scale(0.94)" }}
|
||||
>
|
||||
F
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="fr" className="h-full">
|
||||
<body className="min-h-full antialiased">
|
||||
{children}
|
||||
<Intro />
|
||||
<noscript>
|
||||
<style>{`[data-intro-overlay]{display:none!important}`}</style>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
81
app/not-found.tsx
Normal file
|
|
@ -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<HTMLElement>(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<HTMLElement>("[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 (
|
||||
<main
|
||||
ref={root}
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 text-center"
|
||||
>
|
||||
<div className="flex leading-none font-bold text-fg [font-size:clamp(6rem,26vw,13rem)]">
|
||||
<span data-digit>4</span>
|
||||
<span data-digit className="text-accent">
|
||||
0
|
||||
</span>
|
||||
<span data-digit>4</span>
|
||||
</div>
|
||||
|
||||
<p data-fade className="max-w-full font-mono text-sm break-words text-muted">
|
||||
fatal: le chemin «
|
||||
<span className="text-fg">{pathname}</span>
|
||||
» n'existe pas dans ce dépôt
|
||||
</p>
|
||||
|
||||
<div data-fade className="flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md border border-line bg-surface px-4 py-2 text-sm font-semibold hover:border-muted"
|
||||
>
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
<Link
|
||||
href="/#projets"
|
||||
className="rounded-md border border-line px-4 py-2 text-sm font-semibold text-accent hover:border-muted"
|
||||
>
|
||||
Voir les projets
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
BIN
app/opengraph-image.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
449
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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personLd) }}
|
||||
/>
|
||||
<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{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
<header className="sticky top-0 z-20 border-b border-white/10 bg-[#0a0e17]/72 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-[1280px] items-center gap-3 px-4 py-3 md:px-6">
|
||||
<span className="glass grid size-8 place-items-center rounded-full font-bold">
|
||||
F
|
||||
</span>
|
||||
<span className="font-semibold">Franck Thiaw-Woaye-Lapierre</span>
|
||||
<nav className="ml-auto flex items-center gap-4">
|
||||
<a
|
||||
href="https://forgejo.francktwl.fr/Franck"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-muted hover:text-accent"
|
||||
>
|
||||
git<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:francktwl@francktwl.fr"
|
||||
className="text-muted hover:text-accent"
|
||||
>
|
||||
email
|
||||
</a>
|
||||
<Link
|
||||
href="/cv"
|
||||
className="rounded-md border border-line bg-surface px-3 py-1.5 text-sm font-semibold hover:border-muted"
|
||||
>
|
||||
CV
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto grid max-w-[1100px] gap-6 px-4 pt-6 pb-16 md:grid-cols-[296px_minmax(0,1fr)] md:px-6">
|
||||
<aside data-reveal className="flex flex-col">
|
||||
<div className="flex items-start gap-4 md:block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/pp.jpg"
|
||||
alt="Franck Thiaw-Woaye-Lapierre"
|
||||
className="size-16 shrink-0 rounded-full border border-line object-cover md:h-auto md:w-full md:aspect-square"
|
||||
/>
|
||||
<div className="md:mt-4">
|
||||
<h1 className="text-xl font-semibold leading-tight md:text-2xl">
|
||||
Franck Thiaw-Woaye-Lapierre
|
||||
</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 className="text-base font-light text-muted md:text-xl">
|
||||
@francktwl
|
||||
</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 h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
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>
|
||||
|
||||
<p className="mt-3">
|
||||
<span className="glass inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs">
|
||||
<span aria-hidden="true">💻</span> Développeur & DevOps · La Réunion
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="mt-4">
|
||||
É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.
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 space-y-2 text-muted">
|
||||
<li>
|
||||
<span aria-hidden="true">📍</span> Saint-Paul, La Réunion
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">🎓</span> Licence Informatique · Université
|
||||
de La Réunion
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">🗣️</span> Français (natif) · Anglais
|
||||
(TOEIC)
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">🔗</span>{" "}
|
||||
<a
|
||||
href="https://forgejo.francktwl.fr/Franck"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
forgejo.francktwl.fr<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">✉️</span>{" "}
|
||||
<a
|
||||
href="mailto:francktwl@francktwl.fr"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
francktwl@francktwl.fr
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">📸</span>{" "}
|
||||
<a
|
||||
href="https://www.instagram.com/francktwl_photo/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
@francktwl_photo<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span aria-hidden="true">💬</span>{" "}
|
||||
<a
|
||||
href="https://discordapp.com/users/320512642785607681"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Discord<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0">
|
||||
<section data-reveal className="glass overflow-hidden rounded-2xl">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3 text-muted">
|
||||
<span aria-hidden="true">📖</span>
|
||||
<span className="text-xs">francktwl / README.md</span>
|
||||
</div>
|
||||
<div className="space-y-4 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold">Salut, moi c'est Franck 👋</h2>
|
||||
<p className="max-w-[65ch]">
|
||||
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.
|
||||
</p>
|
||||
<p className="max-w-[65ch]">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{badges.map((b) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={b.alt} src={b.src} alt={b.alt} className="h-7" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-reveal className="mt-6">
|
||||
<h2 className="mb-3 text-base font-semibold">Homelab</h2>
|
||||
<div className="glass rounded-2xl p-4">
|
||||
<p className="text-sm text-muted">
|
||||
J'héberge mes propres services derrière un reverse proxy, sur
|
||||
un serveur dédié en ligne depuis 2020 — tout en conteneurs.
|
||||
</p>
|
||||
<ul className="mt-3 grid gap-x-4 gap-y-2 sm:grid-cols-2">
|
||||
{homelab.map((h) => (
|
||||
<li key={h.name} className="flex items-baseline gap-2 text-sm">
|
||||
<span aria-hidden="true">{h.icon}</span>
|
||||
<span>
|
||||
{h.url ? (
|
||||
<a
|
||||
href={h.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="font-semibold text-accent hover:underline"
|
||||
>
|
||||
{h.name}
|
||||
<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-semibold">{h.name}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="projets" data-reveal className="mt-6 scroll-mt-6">
|
||||
<h2 className="mb-3 text-base font-semibold">Épinglé</h2>
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{repos.map((r) => (
|
||||
<li
|
||||
key={r.name}
|
||||
className="glass flex h-full flex-col rounded-2xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted" aria-hidden="true">
|
||||
📁
|
||||
</span>
|
||||
{r.url ? (
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="font-semibold text-accent hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
<span className="sr-only"> (nouvel onglet)</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-semibold">{r.name}</span>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto rounded-full border px-2 py-0.5 text-xs ${
|
||||
r.archived
|
||||
? "border-[#d29922]/40 text-[#d29922]"
|
||||
: "border-line text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.archived ? "Archivé" : "Public"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">{r.desc}</p>
|
||||
<ul className="mt-3 flex flex-wrap gap-1.5">
|
||||
{r.topics.map((t) => (
|
||||
<li
|
||||
key={t}
|
||||
className="rounded-full bg-accent/10 px-2 py-0.5 text-xs font-medium text-accent"
|
||||
>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-auto flex items-center gap-1.5 pt-3 text-xs text-muted">
|
||||
<span
|
||||
className="inline-block size-3 rounded-full"
|
||||
style={{ backgroundColor: r.langColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{r.lang}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section data-reveal className="mt-6">
|
||||
<h2 className="mb-3 text-base font-semibold">Parcours</h2>
|
||||
<ol className="border-l border-line">
|
||||
{parcours.map((p) => (
|
||||
<li key={p.year} className="relative pb-6 pl-6 last:pb-0">
|
||||
<span
|
||||
className="absolute top-1.5 -left-[5px] size-2.5 rounded-full border-2 border-canvas bg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-xs text-muted">{p.year}</p>
|
||||
<p className="font-semibold">{p.title}</p>
|
||||
<p className="mt-1 max-w-[65ch] text-muted">{p.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
app/robots.ts
Normal file
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
20
app/sitemap.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
BIN
app/twitter-image.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"gsap": "^3.15.0",
|
||||
"next": "16.3.4",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
public/pp.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |