refonte: portfolio statique Astro (design moderne développeur)
Some checks failed
Deploy production / build-deploy (push) Failing after 56s
Some checks failed
Deploy production / build-deploy (push) Failing after 56s
- Remplace l'ancien Astro hybrid + admin par un site one-page statique (sortie static) - Thème clair/sombre sans flash, contenu typé (src/data/content.ts), zéro admin - Icônes simple-icons (SVG monochrome), polices auto-hébergées (RGPD) - SEO complet : canonical, Open Graph, Twitter Card, JSON-LD Person, robots.txt, sitemap.xml, image OG 1200x630 - CI Gitea de déploiement FTPS vers Plesk (.gitea/workflows/prod.yml)
This commit is contained in:
30
src/components/About.astro
Normal file
30
src/components/About.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import { profile, stats } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="about">
|
||||
<div class="reveal about-grid">
|
||||
<div class="about-portrait">
|
||||
<img
|
||||
src="/images/profil/pp3.png"
|
||||
alt="Johan Leroy, développeur fullstack"
|
||||
width="1041"
|
||||
height="1253"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="eyebrow">01 / à propos</div>
|
||||
<h2 class="h2 about-h2">Un développeur qui livre, du code à la prod.</h2>
|
||||
<p class="about-p">{profile.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reveal stats">
|
||||
{stats.map((s) => (
|
||||
<div class="stat">
|
||||
<div class="stat-n">{s.n}</div>
|
||||
<div class="stat-l">{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
// Background ambient fixed behind all content
|
||||
// Layers: gradient mesh base (from body .warehouse-bg) + grain SVG + 3 UV spots parallaxed on scroll
|
||||
---
|
||||
|
||||
<div
|
||||
id="ambient"
|
||||
class="fixed inset-0 -z-10 pointer-events-none overflow-hidden"
|
||||
aria-hidden="true"
|
||||
transition:persist
|
||||
>
|
||||
<!-- UV spot 1 (cyan) -->
|
||||
<div
|
||||
class="spot absolute rounded-full blur-3xl opacity-40 will-change-transform"
|
||||
style="
|
||||
width: 60vw; height: 60vw;
|
||||
top: -10vw; left: -15vw;
|
||||
background: radial-gradient(closest-side, rgba(0, 240, 255, 0.35), transparent 70%);
|
||||
transform: translate3d(0, var(--spot-1-y, 0), 0);
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- UV spot 2 (magenta) -->
|
||||
<div
|
||||
class="spot absolute rounded-full blur-3xl opacity-35 will-change-transform"
|
||||
style="
|
||||
width: 55vw; height: 55vw;
|
||||
top: 40vh; right: -20vw;
|
||||
background: radial-gradient(closest-side, rgba(255, 42, 109, 0.32), transparent 70%);
|
||||
transform: translate3d(0, var(--spot-2-y, 0), 0);
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- UV spot 3 (acid, plus discret) -->
|
||||
<div
|
||||
class="spot absolute rounded-full blur-3xl opacity-25 will-change-transform"
|
||||
style="
|
||||
width: 45vw; height: 45vw;
|
||||
bottom: -10vw; left: 30vw;
|
||||
background: radial-gradient(closest-side, rgba(198, 255, 0, 0.25), transparent 70%);
|
||||
transform: translate3d(0, var(--spot-3-y, 0), 0);
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Grain / noise overlay -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.08] mix-blend-overlay animate-grain"
|
||||
style="
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22200%22><filter id=%22n%22><feTurbulence type=%22fractalNoise%22 baseFrequency=%220.9%22 numOctaves=%222%22 stitchTiles=%22stitch%22/></filter><rect width=%22200%22 height=%22200%22 filter=%22url(%23n)%22 opacity=%220.6%22/></svg>');
|
||||
background-size: 200px 200px;
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Scanlines subtiles fixes -->
|
||||
<div class="absolute inset-0 opacity-[0.03] scanlines"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ambient = document.getElementById('ambient');
|
||||
if (ambient && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
let rafId: number | null = null;
|
||||
const tick = () => {
|
||||
const y = window.scrollY;
|
||||
ambient.style.setProperty('--spot-1-y', `${y * 0.15}px`);
|
||||
ambient.style.setProperty('--spot-2-y', `${y * -0.22}px`);
|
||||
ambient.style.setProperty('--spot-3-y', `${y * 0.18}px`);
|
||||
rafId = null;
|
||||
};
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (rafId === null) rafId = requestAnimationFrame(tick);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
45
src/components/Contact.astro
Normal file
45
src/components/Contact.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import Icon from "./Icon.astro";
|
||||
import { social, coffeeUrl } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="contact">
|
||||
<div class="reveal contact-card">
|
||||
<div class="eyebrow">06 / contact</div>
|
||||
<h2 class="contact-h2">Une idée, un poste, un projet ? Parlons-en.</h2>
|
||||
<p class="contact-p">
|
||||
Disponible pour de nouvelles opportunités. Le plus simple, c'est un email — je réponds vite.
|
||||
</p>
|
||||
|
||||
<div class="contact-cta">
|
||||
<a class="btn-email" href={`mailto:${social.email}`}>
|
||||
<Icon name="mail" size={17} />{social.email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="contact-social">
|
||||
<a class="social-link" href={social.github} target="_blank" rel="noopener" aria-label="GitHub"><Icon name="github" /></a>
|
||||
<a class="social-link" href={social.gitea} target="_blank" rel="noopener" aria-label="Gitea"><Icon name="gitea" /></a>
|
||||
<a class="social-link" href={social.linkedin} target="_blank" rel="noopener" aria-label="LinkedIn"><Icon name="linkedin" /></a>
|
||||
<a class="social-link" href={social.site} target="_blank" rel="noopener" aria-label="Site"><Icon name="site" /></a>
|
||||
</div>
|
||||
|
||||
<div class="echo"><b>$</b> echo "écris-moi"</div>
|
||||
|
||||
<div class="coffee-box">
|
||||
<p>
|
||||
Mes projets open-source ou ce portfolio vous sont utiles ? Vous pouvez m'offrir un café pour
|
||||
soutenir le travail.
|
||||
</p>
|
||||
<a
|
||||
class="btn-coffee-lg"
|
||||
href={coffeeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Offrez-moi un café (ouvre un nouvel onglet)"
|
||||
>
|
||||
<Icon name="coffee" size={17} /> Offrez-moi un café
|
||||
</a>
|
||||
<div class="mono" style="font-size:12px;color:var(--text-dim);">// merci 🙏</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,160 +1,11 @@
|
||||
---
|
||||
import type { Profile } from '../content/schemas';
|
||||
import Waveform from './islands/Waveform';
|
||||
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
}
|
||||
const { profile } = Astro.props;
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="relative mt-24 border-t border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
|
||||
<!-- Waveform séparateur -->
|
||||
<div class="absolute -top-10 inset-x-0 h-10 flex items-end opacity-40 pointer-events-none">
|
||||
<Waveform bars={120} color="var(--color-magenta)" className="w-full h-full" client:visible />
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[2fr_1fr_1fr] gap-10 mb-12">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span
|
||||
class="stencil text-3xl leading-none tracking-wider flex items-baseline gap-0.5"
|
||||
>
|
||||
<span class="text-[color:var(--color-cyan)] glow-text-cyan">J</span>
|
||||
<span class="text-[color:var(--color-acid)] glow-text-acid">L</span>
|
||||
<span class="text-[color:var(--color-magenta)] glow-text-magenta text-sm">•</span>
|
||||
</span>
|
||||
{
|
||||
profile.available && (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-[color:var(--color-text-muted)]">
|
||||
<span class="led led-green animate-led-green" />
|
||||
on air
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--color-text-muted)] max-w-md mb-4">
|
||||
{profile.tagline}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] uppercase tracking-[0.25em] text-[color:var(--color-magenta)]">
|
||||
// code propre, sono sale
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="stencil uppercase tracking-[0.25em] text-xs text-[color:var(--color-cyan)] mb-4">
|
||||
navigate
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm font-mono">
|
||||
<li>
|
||||
<a href="/" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/projets" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> projets
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/experience" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> experience
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/formations" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> formations
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="stencil uppercase tracking-[0.25em] text-xs text-[color:var(--color-acid)] mb-4">
|
||||
open mic
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm font-mono">
|
||||
{
|
||||
profile.socials.email && (
|
||||
<li>
|
||||
<a
|
||||
href={`mailto:${profile.socials.email}`}
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition break-all"
|
||||
>
|
||||
> {profile.socials.email}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.github && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> github ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.gitea && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.gitea}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> gitea ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.linkedin && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> linkedin ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.website && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.website}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> portfolio ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-6 border-t border-[color:var(--color-border)] flex flex-col md:flex-row justify-between items-center gap-2 text-[10px] text-[color:var(--color-text-dim)] font-mono uppercase tracking-widest"
|
||||
>
|
||||
<span>© {year} johan leroy · all rights reserved</span>
|
||||
<span>
|
||||
<span class="text-[color:var(--color-cyan)]"></></span> built with astro · dark mode only
|
||||
</span>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img src="/images/logo/logo.png" alt="Logo Johan Leroy" width="32" height="26" />
|
||||
<span>© 2026 Johan Leroy</span>
|
||||
</div>
|
||||
<span class="footer-credit">Conçu et codé par Johan</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,244 +1,75 @@
|
||||
---
|
||||
import type { Profile } from '../content/schemas';
|
||||
import Icon from "./Icon.astro";
|
||||
import { social, coffeeUrl } from "../data/content";
|
||||
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
}
|
||||
const { profile } = Astro.props;
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Accueil', tag: 'home' },
|
||||
{ href: '/projets', label: 'Projets', tag: 'crate' },
|
||||
{ href: '/experience', label: 'Expérience', tag: 'setlist' },
|
||||
{ href: '/formations', label: 'Formations', tag: 'log' },
|
||||
const nav = [
|
||||
{ label: "À propos", href: "#about", id: "about" },
|
||||
{ label: "Projets", href: "#projets", id: "projets" },
|
||||
{ label: "Parcours", href: "#parcours", id: "parcours" },
|
||||
{ label: "Stack", href: "#stack", id: "stack" },
|
||||
{ label: "Contact", href: "#contact", id: "contact" },
|
||||
];
|
||||
|
||||
const current = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<header
|
||||
class="fixed top-0 inset-x-0 z-50 backdrop-blur-xl bg-[rgba(10,10,15,0.75)] border-b border-[color:var(--color-border)]"
|
||||
transition:animate="none"
|
||||
>
|
||||
<!-- Scroll progress bar -->
|
||||
<div
|
||||
id="scroll-progress"
|
||||
class="absolute bottom-0 left-0 h-px bg-[color:var(--color-acid)] shadow-[0_0_8px_rgba(198,255,0,0.7)] origin-left transition-[width] duration-100 ease-out"
|
||||
style="width: 0%"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 h-16 flex items-center justify-between gap-4">
|
||||
<!-- Logo "JL" stencil + ON AIR -->
|
||||
<a href="/" class="flex items-center gap-3 group shrink-0" aria-label="Johan Leroy — accueil">
|
||||
<span
|
||||
class="stencil text-2xl leading-none tracking-wider flex items-baseline gap-0.5 group-hover:scale-[1.02] transition-transform"
|
||||
>
|
||||
<span class="text-[color:var(--color-cyan)] glow-text-cyan">J</span>
|
||||
<span class="text-[color:var(--color-acid)] glow-text-acid">L</span>
|
||||
<span class="text-[color:var(--color-magenta)] glow-text-magenta text-xs">•</span>
|
||||
</span>
|
||||
{
|
||||
profile.available && (
|
||||
<span class="hidden sm:inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-[color:var(--color-text-muted)]">
|
||||
<span class="led led-green animate-led-green" aria-hidden="true" />
|
||||
on air
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<header class="site-header">
|
||||
<nav class="nav" aria-label="Navigation principale">
|
||||
<a class="nav-logo" href="#top" aria-label="Johan Leroy — accueil">
|
||||
<img src="/images/logo/logo.png" alt="Logo Johan Leroy" width="36" height="29" />
|
||||
</a>
|
||||
|
||||
<!-- Nav desktop -->
|
||||
<nav
|
||||
class="hidden md:flex items-center gap-1 font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||
>
|
||||
{
|
||||
navItems.map((item) => {
|
||||
const active =
|
||||
current === item.href || (item.href !== '/' && current.startsWith(item.href));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'relative group flex items-center gap-2 px-3 py-2 transition-colors',
|
||||
active
|
||||
? 'text-[color:var(--color-cyan)]'
|
||||
: 'text-[color:var(--color-text-muted)] hover:text-[color:var(--color-text)]',
|
||||
]}
|
||||
>
|
||||
<span
|
||||
class:list={[
|
||||
'led transition-all',
|
||||
active ? 'led-cyan' : 'bg-[color:var(--color-border)] shadow-none',
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex flex-col items-start leading-tight">
|
||||
<span class="font-display text-xs tracking-[0.15em]">{item.label}</span>
|
||||
<span
|
||||
class:list={[
|
||||
'text-[9px] opacity-60',
|
||||
active && 'text-[color:var(--color-acid)] opacity-100',
|
||||
]}
|
||||
>
|
||||
// {item.tag}
|
||||
</span>
|
||||
</span>
|
||||
{active && (
|
||||
<span
|
||||
class="absolute -bottom-px left-3 right-3 h-px bg-[color:var(--color-cyan)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</nav>
|
||||
<div class="nav-links">
|
||||
{nav.map((i) => <a class="nav-link" href={i.href} data-nav={i.id}>{i.label}</a>)}
|
||||
</div>
|
||||
|
||||
<!-- Controls (sound toggle + mobile menu) -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Sound toggle -->
|
||||
<button
|
||||
id="sound-toggle"
|
||||
class="p-2 text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition-colors group"
|
||||
aria-label="Activer/désactiver les sons"
|
||||
aria-pressed="false"
|
||||
title="Sons"
|
||||
<div class="nav-actions">
|
||||
<a
|
||||
class="btn-coffee btn-coffee--header"
|
||||
href={coffeeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Offrez-moi un café (ouvre un nouvel onglet)"
|
||||
>
|
||||
<svg
|
||||
class="sound-on hidden w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
class="sound-off w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zM17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="coffee" size={15} /> Café
|
||||
</a>
|
||||
|
||||
<button id="theme-toggle" type="button" class="btn-icon" aria-label="Basculer le thème clair / sombre">
|
||||
<Icon name="sun" class="icon-sun" />
|
||||
<Icon name="moon" class="icon-moon" />
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="md:hidden p-2 text-[color:var(--color-text)]"
|
||||
aria-label="Menu"
|
||||
aria-expanded="false"
|
||||
<a
|
||||
class="btn-icon btn-github--header"
|
||||
href={social.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="GitHub (ouvre un nouvel onglet)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="github" />
|
||||
</a>
|
||||
|
||||
<button
|
||||
id="burger"
|
||||
type="button"
|
||||
class="burger"
|
||||
aria-label="Ouvrir le menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<Icon name="burger" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="md:hidden hidden border-t border-[color:var(--color-border)] bg-[rgba(10,10,15,0.97)]"
|
||||
>
|
||||
<nav class="container mx-auto px-4 py-4 flex flex-col gap-1 font-mono uppercase tracking-wider">
|
||||
{
|
||||
navItems.map((item) => {
|
||||
const active =
|
||||
current === item.href || (item.href !== '/' && current.startsWith(item.href));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'flex items-center gap-3 px-3 py-3 transition-colors',
|
||||
active
|
||||
? 'text-[color:var(--color-cyan)] bg-[rgba(0,240,255,0.04)]'
|
||||
: 'text-[color:var(--color-text-muted)] hover:text-[color:var(--color-text)]',
|
||||
]}
|
||||
>
|
||||
<span
|
||||
class:list={[
|
||||
'led',
|
||||
active ? 'led-cyan' : 'bg-[color:var(--color-border)] shadow-none',
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="font-display text-sm">{item.label}</span>
|
||||
<span class="ml-auto text-[10px] opacity-50">// {item.tag}</span>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</nav>
|
||||
<div id="mobile-menu" class="mobile-menu">
|
||||
{nav.map((i) => <a class="mobile-link" href={i.href}>{i.label}</a>)}
|
||||
<div class="mobile-cta">
|
||||
<a class="m-github" href={social.github} target="_blank" rel="noopener">GitHub</a>
|
||||
<a
|
||||
class="m-coffee"
|
||||
href={coffeeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Offrez-moi un café (ouvre un nouvel onglet)"
|
||||
>☕ Un café</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
// Mobile menu
|
||||
const btn = document.getElementById('mobile-menu-btn');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
btn?.addEventListener('click', () => {
|
||||
const open = menu?.classList.toggle('hidden') === false;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// Scroll progress bar
|
||||
const progress = document.getElementById('scroll-progress');
|
||||
function updateProgress() {
|
||||
if (!progress) return;
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const pct = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0;
|
||||
progress.style.width = `${pct}%`;
|
||||
}
|
||||
window.addEventListener('scroll', updateProgress, { passive: true });
|
||||
window.addEventListener('resize', updateProgress);
|
||||
updateProgress();
|
||||
|
||||
// Sound toggle (state in localStorage, off by default)
|
||||
const soundBtn = document.getElementById('sound-toggle');
|
||||
const onIcon = soundBtn?.querySelector('.sound-on');
|
||||
const offIcon = soundBtn?.querySelector('.sound-off');
|
||||
|
||||
function applySoundState(on) {
|
||||
if (!soundBtn || !onIcon || !offIcon) return;
|
||||
soundBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
onIcon.classList.toggle('hidden', !on);
|
||||
offIcon.classList.toggle('hidden', on);
|
||||
window.__soundOn = on;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('portfolio:sound');
|
||||
applySoundState(stored === 'on');
|
||||
|
||||
soundBtn?.addEventListener('click', () => {
|
||||
const next = soundBtn.getAttribute('aria-pressed') !== 'true';
|
||||
localStorage.setItem('portfolio:sound', next ? 'on' : 'off');
|
||||
applySoundState(next);
|
||||
});
|
||||
</script>
|
||||
|
||||
41
src/components/Hero.astro
Normal file
41
src/components/Hero.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import Icon from "./Icon.astro";
|
||||
import { profile, social, terminalText } from "../data/content";
|
||||
---
|
||||
<section class="section container hero">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<span class="badge"><span class="badge-dot"></span>Ouvert aux opportunités</span>
|
||||
<h1>{profile.name}</h1>
|
||||
<p class="hero-role">{profile.role}</p>
|
||||
<p class="hero-lede">{profile.lede}</p>
|
||||
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="/cv.pdf" download>
|
||||
<Icon name="download" size={16} /> Télécharger mon CV
|
||||
</a>
|
||||
<a class="btn btn-secondary" href="#projets">
|
||||
Voir mes projets <Icon name="arrow" size={16} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="social-row">
|
||||
<a class="social-link" href={social.github} target="_blank" rel="noopener" aria-label="GitHub"><Icon name="github" /></a>
|
||||
<a class="social-link" href={social.gitea} target="_blank" rel="noopener" aria-label="Gitea auto-hébergé"><Icon name="gitea" /></a>
|
||||
<a class="social-link" href={social.linkedin} target="_blank" rel="noopener" aria-label="LinkedIn"><Icon name="linkedin" /></a>
|
||||
<a class="social-link" href={`mailto:${social.email}`} aria-label="Envoyer un email"><Icon name="mail" /></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="terminal-bar">
|
||||
<span class="dot dot-r"></span>
|
||||
<span class="dot dot-y"></span>
|
||||
<span class="dot dot-g"></span>
|
||||
<span class="terminal-label">johanleroy.fr ~ %</span>
|
||||
<img class="terminal-avatar" src="/images/profil/pp2.png" alt="Photo de Johan Leroy" width="28" height="28" loading="lazy" />
|
||||
</div>
|
||||
<pre class="terminal-body"><span id="typed">{terminalText}</span><span class="caret" aria-hidden="true"></span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
55
src/components/Icon.astro
Normal file
55
src/components/Icon.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/* Icônes d'interface (SVG inline, currentColor). fill pour les logos
|
||||
pleins (github/linkedin), stroke pour le reste (style lucide). */
|
||||
interface Props {
|
||||
name:
|
||||
| "github" | "gitea" | "linkedin" | "mail" | "site"
|
||||
| "arrow" | "download" | "external" | "coffee"
|
||||
| "sun" | "moon" | "burger"
|
||||
| "gamepad" | "film" | "music" | "code";
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
const { name, size = 18, class: className } = Astro.props;
|
||||
|
||||
const FILL = new Set(["github", "linkedin"]);
|
||||
const STROKE_WIDTH: Record<string, number> = {
|
||||
arrow: 2, external: 2, burger: 2, globe: 1.7, download: 1.9, site: 1.7,
|
||||
};
|
||||
|
||||
const PATHS: Record<string, string> = {
|
||||
github: `<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23a11.5 11.5 0 0 1 3.003-.404c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>`,
|
||||
linkedin: `<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>`,
|
||||
gitea: `<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>`,
|
||||
mail: `<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m3 6 9 7 9-7"/>`,
|
||||
site: `<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/>`,
|
||||
arrow: `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>`,
|
||||
download: `<path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M5 21h14"/>`,
|
||||
external: `<path d="M7 17 17 7"/><path d="M8 7h9v9"/>`,
|
||||
coffee: `<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8Z"/><line x1="6" y1="2" x2="6" y2="5"/><line x1="10" y1="2" x2="10" y2="5"/><line x1="14" y1="2" x2="14" y2="5"/>`,
|
||||
sun: `<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>`,
|
||||
moon: `<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>`,
|
||||
burger: `<path d="M3 6h18M3 12h18M3 18h18"/>`,
|
||||
gamepad: `<line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><rect x="2" y="6" width="20" height="12" rx="4"/>`,
|
||||
film: `<rect x="2" y="2" width="20" height="20" rx="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/>`,
|
||||
music: `<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>`,
|
||||
code: `<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
|
||||
};
|
||||
|
||||
const isFill = FILL.has(name);
|
||||
const sw = STROKE_WIDTH[name] ?? 1.8;
|
||||
const inner = PATHS[name] ?? "";
|
||||
---
|
||||
<svg
|
||||
class:list={["ic", className]}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={isFill ? "currentColor" : "none"}
|
||||
stroke={isFill ? undefined : "currentColor"}
|
||||
stroke-width={isFill ? undefined : sw}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
set:html={inner}
|
||||
></svg>
|
||||
19
src/components/Interests.astro
Normal file
19
src/components/Interests.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Icon from "./Icon.astro";
|
||||
import { interests } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="interets">
|
||||
<div class="reveal" style="margin-bottom:32px;">
|
||||
<div class="eyebrow">05 / à-côté</div>
|
||||
<h2 class="h2">Centres d'intérêt</h2>
|
||||
</div>
|
||||
<div class="reveal interests">
|
||||
{interests.map((it) => (
|
||||
<div class="interest">
|
||||
<span class="interest-ic"><Icon name={it.icon} size={20} /></span>
|
||||
<div class="interest-name">{it.name}</div>
|
||||
<div class="interest-note">{it.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,93 +1,40 @@
|
||||
---
|
||||
import type { Project } from '../content/schemas';
|
||||
import Icon from "./Icon.astro";
|
||||
import type { Project } from "../data/content";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const isArchived = project.status === 'archived';
|
||||
const isPrivate = project.status === 'private';
|
||||
const statusLabel =
|
||||
project.status === 'live'
|
||||
? 'Live'
|
||||
: project.status === 'archived'
|
||||
? 'Archivé'
|
||||
: project.status === 'private'
|
||||
? 'Privé'
|
||||
: 'WIP';
|
||||
const STATUS: Record<Project["status"], { label: string; color: string }> = {
|
||||
live: { label: "en ligne", color: "var(--accent)" },
|
||||
archived: { label: "archivé", color: "var(--text-dim)" },
|
||||
oss: { label: "open-source", color: "var(--info)" },
|
||||
};
|
||||
|
||||
const Tag = project.url && !isArchived && !isPrivate ? 'a' : 'div';
|
||||
const tagProps = project.url && !isArchived && !isPrivate
|
||||
? { href: project.url, target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {};
|
||||
interface Props { project: Project; }
|
||||
const { project: p } = Astro.props;
|
||||
const st = STATUS[p.status];
|
||||
const mono = p.title.replace(/[^A-Za-z0-9]/g, "").slice(0, 2);
|
||||
---
|
||||
|
||||
<Tag
|
||||
{...tagProps}
|
||||
class:list={[
|
||||
'group card-neon block h-full overflow-hidden',
|
||||
isArchived && 'opacity-70',
|
||||
]}
|
||||
>
|
||||
<div class="relative aspect-[16/9] -mx-6 -mt-6 mb-4 overflow-hidden bg-[color:var(--color-bg)]">
|
||||
{project.image ? (
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
class:list={[isArchived && 'grayscale']}
|
||||
/>
|
||||
<article class="card">
|
||||
<div class="card-media">
|
||||
{p.img ? (
|
||||
<img src={p.img} alt={p.title} loading="lazy" />
|
||||
) : (
|
||||
<div class="w-full h-full flex items-center justify-center text-[color:var(--color-text-dim)] font-mono text-xs aurora-bg">
|
||||
// no preview
|
||||
</div>
|
||||
<div class="card-mono"><span>{mono}</span></div>
|
||||
)}
|
||||
|
||||
<div class="absolute top-3 right-3 flex gap-2">
|
||||
<span
|
||||
class:list={[
|
||||
'text-[10px] font-mono uppercase tracking-wider px-2 py-1 rounded-sm border backdrop-blur-md',
|
||||
project.status === 'live' && 'border-[color:var(--color-cyan)] text-[color:var(--color-cyan)] bg-[rgba(0,240,255,0.1)]',
|
||||
project.status === 'archived' && 'border-[color:var(--color-text-dim)] text-[color:var(--color-text-dim)] bg-[rgba(0,0,0,0.5)]',
|
||||
project.status === 'private' && 'border-[color:var(--color-magenta)] text-[color:var(--color-magenta)] bg-[rgba(255,42,109,0.1)]',
|
||||
project.status === 'wip' && 'border-[color:var(--color-violet)] text-[color:var(--color-violet)] bg-[rgba(185,103,255,0.1)]',
|
||||
]}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-head">
|
||||
<span class="status"><span class="status-dot" style={`background:${st.color}`}></span>{st.label}</span>
|
||||
{p.link && (
|
||||
<a class="ext-link" href={p.link} target="_blank" rel="noopener" aria-label="Voir le projet (nouvel onglet)">
|
||||
<Icon name="external" size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="card-title">{p.title}</h3>
|
||||
<p class="card-sub">{p.subtitle}</p>
|
||||
<p class="card-desc">{p.desc}</p>
|
||||
<div class="chips">
|
||||
{p.stack.map((s) => <span class="chip">{s}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-display text-lg mb-1 text-[color:var(--color-text)] group-hover:glow-text-cyan group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
{project.subtitle && (
|
||||
<p class="text-xs font-mono text-[color:var(--color-text-dim)] mb-3 uppercase tracking-wider">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<p class="text-sm text-[color:var(--color-text-muted)] mb-4 line-clamp-3">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{project.stack.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
{project.stack.slice(0, 5).map((tech) => (
|
||||
<span class="chip">{tech}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.url && !isArchived && !isPrivate && (
|
||||
<span class="text-xs font-mono text-[color:var(--color-cyan)] group-hover:underline">
|
||||
→ Voir le projet
|
||||
</span>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span class="text-xs font-mono text-[color:var(--color-text-dim)]">
|
||||
// projet fermé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
</article>
|
||||
|
||||
20
src/components/Projects.astro
Normal file
20
src/components/Projects.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import ProjectCard from "./ProjectCard.astro";
|
||||
import { projectsFeatured, projectsOther } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="projets">
|
||||
<div class="reveal" style="margin-bottom:36px;">
|
||||
<div class="eyebrow">02 / projets</div>
|
||||
<h2 class="h2">Réalisations</h2>
|
||||
</div>
|
||||
|
||||
<div class="reveal label-mono" style="margin-bottom:16px;">// projets phares</div>
|
||||
<div class="reveal grid-projects">
|
||||
{projectsFeatured.map((p) => <ProjectCard project={p} />)}
|
||||
</div>
|
||||
|
||||
<div class="reveal label-mono" style="margin:38px 0 16px;">// autres projets</div>
|
||||
<div class="reveal grid-projects is-other">
|
||||
{projectsOther.map((p) => <ProjectCard project={p} />)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
import type { Site } from '../content/schemas';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
site: Site;
|
||||
}
|
||||
|
||||
const { title, description, site } = Astro.props;
|
||||
const pageTitle = title ? `${title} — ${site.title}` : site.title;
|
||||
const pageDescription = description ?? site.description;
|
||||
const canonical = new URL(Astro.url.pathname, site.url).toString();
|
||||
const ogImage = new URL(site.ogImage, site.url).toString();
|
||||
---
|
||||
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="keywords" content={site.keywords.join(', ')} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
48
src/components/Stack.astro
Normal file
48
src/components/Stack.astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import TechIcon from "./TechIcon.astro";
|
||||
import { stackMain, stackSecondary, softTech, softHuman } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="stack">
|
||||
<div class="reveal" style="margin-bottom:36px;">
|
||||
<div class="eyebrow">04 / stack</div>
|
||||
<h2 class="h2">Stack & compétences</h2>
|
||||
</div>
|
||||
|
||||
<div class="reveal stack-grid">
|
||||
{stackMain.map((g) => (
|
||||
<div class="stack-group">
|
||||
<div class="stack-group-label">{g.label}</div>
|
||||
<div class="tiles">
|
||||
{g.items.map((it) => (
|
||||
<div class="tile"><TechIcon name={it} /><span class="tile-name">{it}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="reveal label-mono" style="margin:36px 0 16px;">// également à l'aise avec</div>
|
||||
<div class="reveal stack-grid is-secondary">
|
||||
{stackSecondary.map((g) => (
|
||||
<div class="stack-group">
|
||||
<div class="stack-group-label">{g.label}</div>
|
||||
<div class="tiles">
|
||||
{g.items.map((it) => (
|
||||
<div class="tile"><TechIcon name={it} /><span class="tile-name">{it}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="reveal soft">
|
||||
<div>
|
||||
<div class="soft-label">// techniques</div>
|
||||
<div class="pills">{softTech.map((s) => <span class="pill">{s}</span>)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="soft-label">// transverses</div>
|
||||
<div class="pills">{softHuman.map((s) => <span class="pill">{s}</span>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
17
src/components/TechIcon.astro
Normal file
17
src/components/TechIcon.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
/* Icône d'une techno : SVG simple-icons (currentColor) si dispo,
|
||||
sinon monogramme. Taille pilotée par le CSS (.tile .ic). */
|
||||
import { techIcons, monogram, type TechDef } from "../lib/techIcons";
|
||||
interface Props { name: string; }
|
||||
const { name } = Astro.props;
|
||||
const def: TechDef | undefined = techIcons[name];
|
||||
const path = def && "path" in def ? def.path : null;
|
||||
const mono = def && "mono" in def ? def.mono : monogram(name);
|
||||
---
|
||||
{path ? (
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
) : (
|
||||
<span class="tile-mono">{mono}</span>
|
||||
)}
|
||||
51
src/components/Timeline.astro
Normal file
51
src/components/Timeline.astro
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import { experiences, formation } from "../data/content";
|
||||
---
|
||||
<section class="section container" id="parcours">
|
||||
<div class="reveal" style="margin-bottom:40px;">
|
||||
<div class="eyebrow">03 / parcours</div>
|
||||
<h2 class="h2">Expériences</h2>
|
||||
</div>
|
||||
|
||||
<div class="reveal timeline">
|
||||
<div class="timeline-line"></div>
|
||||
{experiences.map((e) => (
|
||||
<div class="exp">
|
||||
<div class="exp-logo"><img src={e.logo} alt={e.company} loading="lazy" /></div>
|
||||
<div class="exp-card">
|
||||
<div class="exp-head">
|
||||
<h3 class="exp-role">{e.role}</h3>
|
||||
<span class="exp-company">
|
||||
{e.current && <span class="badge-dot"></span>}
|
||||
{e.company}
|
||||
</span>
|
||||
</div>
|
||||
<div class="exp-period">{e.period} · {e.duration}</div>
|
||||
<p class="exp-desc">{e.desc}</p>
|
||||
{e.missions.length > 0 && (
|
||||
<ul class="exp-missions">
|
||||
{e.missions.map((m) => <li>{m}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{e.stack.length > 0 && (
|
||||
<div class="chips">
|
||||
{e.stack.map((s) => <span class="chip">{s}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="reveal label-mono" style="margin:14px 0 16px;">// formation</div>
|
||||
<div class="reveal formation-grid">
|
||||
{formation.map((f) => (
|
||||
<div class="formation-card">
|
||||
<div class="formation-period">{f.period}</div>
|
||||
<h4 class="formation-title">{f.title}</h4>
|
||||
<div class="formation-school">{f.school}</div>
|
||||
{f.desc && <p class="formation-desc">{f.desc}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ContentKey } from '../../content/schemas';
|
||||
import { EditTabs } from './EditTabs';
|
||||
import { RawJsonEditor } from './RawJsonEditor';
|
||||
import { ProfileForm } from './forms/ProfileForm';
|
||||
import { SkillsForm } from './forms/SkillsForm';
|
||||
import { ExperiencesForm } from './forms/ExperiencesForm';
|
||||
import { FormationsForm } from './forms/FormationsForm';
|
||||
import { ProjectsForm } from './forms/ProjectsForm';
|
||||
import { InterestsForm } from './forms/InterestsForm';
|
||||
import { SiteForm } from './forms/SiteForm';
|
||||
|
||||
type Props = {
|
||||
section: ContentKey;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export function EditPanel({ section, data }: Props) {
|
||||
return (
|
||||
<EditTabs
|
||||
section={section}
|
||||
formNode={renderForm(section, data)}
|
||||
rawNode={<RawJsonEditor section={section} initial={data} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderForm(section: ContentKey, data: unknown) {
|
||||
switch (section) {
|
||||
case 'profile':
|
||||
return <ProfileForm defaultValues={data as never} />;
|
||||
case 'skills':
|
||||
return <SkillsForm defaultValues={data as never} />;
|
||||
case 'experiences':
|
||||
return <ExperiencesForm defaultValues={data as never} />;
|
||||
case 'formations':
|
||||
return <FormationsForm defaultValues={data as never} />;
|
||||
case 'projects':
|
||||
return <ProjectsForm defaultValues={data as never} />;
|
||||
case 'interests':
|
||||
return <InterestsForm defaultValues={data as never} />;
|
||||
case 'site':
|
||||
return <SiteForm defaultValues={data as never} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type Tab = 'form' | 'raw';
|
||||
|
||||
type Props = {
|
||||
section: string;
|
||||
formNode: ReactNode;
|
||||
rawNode: ReactNode;
|
||||
};
|
||||
|
||||
export function EditTabs({ section, formNode, rawNode }: Props) {
|
||||
const storageKey = `admin:tab:${section}`;
|
||||
const [tab, setTab] = useState<Tab>('form');
|
||||
|
||||
useEffect(() => {
|
||||
const saved = sessionStorage.getItem(storageKey) as Tab | null;
|
||||
if (saved === 'form' || saved === 'raw') setTab(saved);
|
||||
}, [storageKey]);
|
||||
|
||||
const switchTo = (t: Tab) => {
|
||||
setTab(t);
|
||||
sessionStorage.setItem(storageKey, t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Mode d'édition"
|
||||
className="flex gap-1 border-b border-[color:var(--color-border)] mb-6"
|
||||
>
|
||||
<TabButton
|
||||
active={tab === 'form'}
|
||||
onClick={() => switchTo('form')}
|
||||
label="Formulaire"
|
||||
tag="// guidé"
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'raw'}
|
||||
onClick={() => switchTo('raw')}
|
||||
label="JSON brut"
|
||||
tag="// avancé"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div role="tabpanel" hidden={tab !== 'form'}>
|
||||
{tab === 'form' && formNode}
|
||||
</div>
|
||||
<div role="tabpanel" hidden={tab !== 'raw'}>
|
||||
{tab === 'raw' && rawNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
tag,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tag: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 font-display uppercase tracking-widest text-sm transition-colors border-b-2 ${
|
||||
active
|
||||
? 'text-[color:var(--color-acid)] border-[color:var(--color-acid)] glow-text-acid'
|
||||
: 'text-[color:var(--color-text-muted)] border-transparent hover:text-[color:var(--color-cyan)]'
|
||||
}`}
|
||||
>
|
||||
<span className="block leading-none">{label}</span>
|
||||
<span className="block font-mono text-[9px] opacity-70 mt-0.5">{tag}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
FormProvider,
|
||||
useForm,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
type Resolver,
|
||||
} from 'react-hook-form';
|
||||
|
||||
type Props<T extends FieldValues> = {
|
||||
section: string;
|
||||
defaultValues: DefaultValues<T>;
|
||||
resolver: Resolver<T>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function FormShell<T extends FieldValues>({
|
||||
section,
|
||||
defaultValues,
|
||||
resolver,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const methods = useForm<T>({ defaultValues, resolver, mode: 'onBlur' });
|
||||
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||
|
||||
const onSubmit = methods.handleSubmit(async (values) => {
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
const res = await fetch(`/api/content/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: 'saved' });
|
||||
methods.reset(values as DefaultValues<T>);
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 2500);
|
||||
} catch (e) {
|
||||
setStatus({ kind: 'error', message: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onSubmit]);
|
||||
|
||||
const dirty = methods.formState.isDirty;
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="sticky top-16 z-30 -mx-4 px-4 py-3 bg-[rgba(10,10,15,0.85)] backdrop-blur-xl border-b border-[color:var(--color-border)] flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Pill status={status} dirty={dirty} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => methods.reset()}
|
||||
className="font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)] hover:text-[color:var(--color-magenta)] px-3 py-1.5 transition-colors"
|
||||
>
|
||||
annuler
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.kind === 'saving'}
|
||||
className="btn-primary text-xs py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">{children}</div>
|
||||
|
||||
<div className="pt-6 border-t border-[color:var(--color-border)] flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.kind === 'saving'}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ status, dirty }: { status: Status; dirty: boolean }) {
|
||||
if (status.kind === 'saving')
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||||
<span className="led led-acid animate-led-red" />
|
||||
enregistrement…
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'saved')
|
||||
return (
|
||||
<span className="sticker text-[color:var(--color-cyan)] glow-text-cyan animate-pulse-neon">
|
||||
✓ saved
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'error')
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)]"
|
||||
title={status.message}
|
||||
>
|
||||
<span className="led led-red animate-led-red" />
|
||||
✗ {status.message.slice(0, 80)}
|
||||
</span>
|
||||
);
|
||||
if (dirty)
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||||
<span className="led" style={{ background: 'var(--color-text-muted)', boxShadow: 'none' }} />
|
||||
modifications non sauvées
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
<span className="led led-cyan" />
|
||||
synced
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
section: string;
|
||||
initial: unknown;
|
||||
};
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'invalid' };
|
||||
|
||||
export function RawJsonEditor({ section, initial }: Props) {
|
||||
const [value, setValue] = useState(() => JSON.stringify(initial, null, 2));
|
||||
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const format = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
setValue(JSON.stringify(parsed, null, 2));
|
||||
setStatus({ kind: 'idle' });
|
||||
} catch {
|
||||
setStatus({ kind: 'invalid' });
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(value);
|
||||
} catch {
|
||||
setStatus({ kind: 'invalid' });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
const res = await fetch(`/api/content/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: 'saved' });
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 2500);
|
||||
} catch (e) {
|
||||
setStatus({ kind: 'error', message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
};
|
||||
const ta = taRef.current;
|
||||
ta?.addEventListener('keydown', onKey as EventListener);
|
||||
return () => ta?.removeEventListener('keydown', onKey as EventListener);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p className="font-mono text-xs text-[color:var(--color-text-dim)]">
|
||||
Mode avancé — JSON brut, validé Zod côté serveur.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusPill status={status} />
|
||||
<button type="button" onClick={format} className="btn-ghost text-xs py-1.5">
|
||||
Formater
|
||||
</button>
|
||||
<button type="button" onClick={save} className="btn-primary text-xs py-1.5">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[400px] h-[calc(100vh-300px)] max-h-[80vh] bg-[color:var(--color-surface)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-4 rounded text-sm font-mono text-[color:var(--color-text)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: Status }) {
|
||||
if (status.kind === 'idle') return null;
|
||||
if (status.kind === 'saving')
|
||||
return (
|
||||
<span className="font-mono text-xs text-[color:var(--color-text-muted)]">
|
||||
enregistrement…
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'saved')
|
||||
return (
|
||||
<span className="font-mono text-xs text-[color:var(--color-cyan)] glow-text-cyan">
|
||||
✓ enregistré
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'invalid')
|
||||
return (
|
||||
<span className="font-mono text-xs text-[color:var(--color-magenta)]">
|
||||
JSON invalide
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="font-mono text-xs text-[color:var(--color-magenta)]" title={status.message}>
|
||||
✗ {status.message.slice(0, 60)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Section({ title, subtitle, children }: Props) {
|
||||
return (
|
||||
<section className="card-neon space-y-4">
|
||||
<header className="flex items-baseline gap-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||
<h2 className="font-display uppercase tracking-widest text-lg text-[color:var(--color-cyan)] glow-text-cyan">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="font-mono text-xs text-[color:var(--color-text-dim)]">{subtitle}</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
type Props<T> = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
newItem: () => T;
|
||||
itemLabel?: (index: number) => string;
|
||||
children: (params: { index: number; namePrefix: string }) => ReactNode;
|
||||
};
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
newItem,
|
||||
itemLabel,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove, move } = useFieldArray({ control, name });
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display uppercase tracking-widest text-base text-[color:var(--color-acid)] glow-text-acid">
|
||||
{label}
|
||||
</h3>
|
||||
{hint && (
|
||||
<p className="font-mono text-[10px] text-[color:var(--color-text-dim)] mt-0.5">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append(newItem() as never)}
|
||||
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest text-[color:var(--color-bg)] bg-[color:var(--color-acid)] rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
+ ajouter
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="font-mono text-xs text-[color:var(--color-text-dim)] italic border border-dashed border-[color:var(--color-border)] rounded p-4 text-center">
|
||||
(aucun élément — clique « ajouter » pour commencer)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol className="space-y-3">
|
||||
{fields.map((field, idx) => (
|
||||
<li
|
||||
key={field.id}
|
||||
className="border border-[color:var(--color-border)] rounded bg-[color:var(--color-surface)] p-4 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||||
{itemLabel ? itemLabel(idx) : `# ${String(idx + 1).padStart(2, '0')}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => idx > 0 && move(idx, idx - 1)}
|
||||
disabled={idx === 0}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||
aria-label="monter"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => idx < fields.length - 1 && move(idx, idx + 1)}
|
||||
disabled={idx === fields.length - 1}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||
aria-label="descendre"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] hover:bg-[color:var(--color-magenta)] rounded transition-colors"
|
||||
aria-label="supprimer"
|
||||
>
|
||||
supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{children({ index: idx, namePrefix: `${name}.${idx}` })}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState, type KeyboardEvent } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function ChipsField({ name, label, hint, placeholder = 'Ajouter puis Entrée…' }: Props) {
|
||||
const { control } = useFormContext();
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const items: string[] = Array.isArray(field.value) ? field.value : [];
|
||||
|
||||
const add = () => {
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
if (items.includes(value)) {
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
field.onChange([...items, value]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
field.onChange(items.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
} else if (e.key === 'Backspace' && draft === '' && items.length > 0) {
|
||||
field.onChange(items.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{items.map((item, idx) => (
|
||||
<span
|
||||
key={`${item}-${idx}`}
|
||||
className="chip group cursor-pointer"
|
||||
onClick={() => remove(idx)}
|
||||
title="Cliquer pour retirer"
|
||||
>
|
||||
{item}
|
||||
<span className="ml-1 opacity-50 group-hover:opacity-100 group-hover:text-[color:var(--color-magenta)]">
|
||||
×
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest border border-[color:var(--color-cyan)] text-[color:var(--color-cyan)] rounded hover:bg-[color:var(--color-cyan)] hover:text-[color:var(--color-bg)] transition-colors"
|
||||
>
|
||||
+ add
|
||||
</button>
|
||||
</div>
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
const SUGGESTIONS = ['⚡', '🚀', '💼', '🎓', '🎧', '🛠️', '🎵', '💻', '📱', '🤖', '☁️', '🔥', '✨', '🎯', '🎨', '🌐'];
|
||||
|
||||
export function EmojiField({ name, label, hint }: Props) {
|
||||
const { register, setValue, watch } = useFormContext();
|
||||
const value = watch(name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl w-12 h-12 flex items-center justify-center bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] rounded">
|
||||
{value || '·'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={4}
|
||||
{...register(name)}
|
||||
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||
placeholder="emoji ou caractère"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setValue(name, s, { shouldDirty: true, shouldValidate: true })}
|
||||
className="w-8 h-8 flex items-center justify-center text-base hover:bg-[color:var(--color-surface-2)] rounded transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useFormContext, get } from 'react-hook-form';
|
||||
|
||||
type Props = { name: string };
|
||||
|
||||
export function FieldError({ name }: Props) {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
const error = get(errors, name);
|
||||
if (!error?.message) return null;
|
||||
return (
|
||||
<p className="mt-1 flex items-center gap-2 font-mono text-xs text-[color:var(--color-magenta)]">
|
||||
<span className="led led-red animate-led-red" />
|
||||
<span>{String(error.message)}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Kind = 'image' | 'document';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
folder?: string;
|
||||
hint?: string;
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
kind?: Kind;
|
||||
};
|
||||
|
||||
const ACCEPT: Record<Kind, string> = {
|
||||
image: 'image/png,image/jpeg,image/webp,image/svg+xml,image/gif',
|
||||
document: 'application/pdf,.pdf',
|
||||
};
|
||||
|
||||
const HELP_TEXT: Record<Kind, string> = {
|
||||
image: 'png, jpg, webp, svg, gif',
|
||||
document: 'pdf uniquement',
|
||||
};
|
||||
|
||||
function fileNameFromUrl(url: string): string {
|
||||
try {
|
||||
const clean = url.split('?')[0];
|
||||
const segments = clean.split('/');
|
||||
return segments[segments.length - 1] || url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileUploadField({
|
||||
name,
|
||||
label,
|
||||
folder = '',
|
||||
hint,
|
||||
accept,
|
||||
maxSizeMb,
|
||||
kind = 'image',
|
||||
}: Props) {
|
||||
const { control } = useFormContext();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [hover, setHover] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const effectiveAccept = accept ?? ACCEPT[kind];
|
||||
const effectiveMax = maxSizeMb ?? (kind === 'document' ? 8 : 5);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const url: string = field.value || '';
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setLocalError(null);
|
||||
if (file.size > effectiveMax * 1024 * 1024) {
|
||||
setLocalError(`Fichier trop lourd (max ${effectiveMax} Mo).`);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('kind', kind);
|
||||
if (folder) fd.append('folder', folder);
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.url) {
|
||||
setLocalError(data?.error || 'upload échoué');
|
||||
return;
|
||||
}
|
||||
field.onChange(data.url);
|
||||
} catch (e) {
|
||||
setLocalError((e as Error).message || 'upload échoué');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setHover(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) upload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||||
{url && kind === 'image' ? (
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-28 h-28 object-cover border border-[color:var(--color-cyan)] rounded"
|
||||
/>
|
||||
<span className="sticker absolute -top-2 -left-2 text-[color:var(--color-cyan)]">
|
||||
ok
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{url && kind === 'document' ? (
|
||||
<div className="relative shrink-0 w-full sm:w-44 p-3 border border-[color:var(--color-cyan)] rounded bg-[color:var(--color-surface-2)] flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||||
pdf actuel
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-[color:var(--color-text)] truncate">
|
||||
{fileNameFromUrl(url)}
|
||||
</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-acid)] hover:underline"
|
||||
>
|
||||
voir le fichier ↗
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setHover(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setHover(true);
|
||||
}}
|
||||
onDragLeave={() => setHover(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className={`flex-1 cursor-pointer p-4 rounded text-center transition-all ${
|
||||
hover
|
||||
? 'neon-border-cyan glow-cyan'
|
||||
: 'border border-dashed border-[color:var(--color-border)] hover:border-[color:var(--color-cyan)]'
|
||||
}`}
|
||||
>
|
||||
{busy ? (
|
||||
<div className="flex items-center justify-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||||
<span className="led led-acid animate-led-red" />
|
||||
upload en cours…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||||
<span className="text-[color:var(--color-cyan)]">
|
||||
{url ? '↻ remplacer' : '↑ glisser-déposer'}
|
||||
</span>
|
||||
<span className="text-[10px] text-[color:var(--color-text-dim)] normal-case tracking-normal">
|
||||
ou cliquer · {HELP_TEXT[kind]} (max {effectiveMax} Mo)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => field.onChange('')}
|
||||
className="self-start sm:self-auto shrink-0 px-2 py-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)] border border-[color:var(--color-magenta)] rounded hover:bg-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={effectiveAccept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
{url && (
|
||||
<p className="mt-1 font-mono text-[10px] text-[color:var(--color-text-dim)] truncate">
|
||||
{url}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{localError && (
|
||||
<p className="mt-1 flex items-center gap-2 font-mono text-xs text-[color:var(--color-magenta)]">
|
||||
<span className="led led-red animate-led-red" />
|
||||
{localError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { FileUploadField } from './FileUploadField';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
folder?: string;
|
||||
hint?: string;
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
};
|
||||
|
||||
export function ImageUploadField(props: Props) {
|
||||
return <FileUploadField {...props} kind="image" />;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function SelectField({ name, label, options, hint }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
{...register(name)}
|
||||
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
mono?: boolean;
|
||||
};
|
||||
|
||||
export function TextAreaField({ name, label, hint, placeholder, rows = 4, mono = false }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<textarea
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
{...register(name)}
|
||||
className={`w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors resize-y ${mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'url';
|
||||
};
|
||||
|
||||
export function TextField({ name, label, hint, placeholder, type = 'text' }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
{...register(name)}
|
||||
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||
/>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function ToggleField({ name, label, hint }: Props) {
|
||||
const { control } = useFormContext();
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const on = !!field.value;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
onClick={() => field.onChange(!on)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded border bg-[color:var(--color-surface-2)] transition-colors ${
|
||||
on
|
||||
? 'border-[color:var(--color-acid)] text-[color:var(--color-acid)]'
|
||||
: 'border-[color:var(--color-border)] text-[color:var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`led ${on ? 'led-acid animate-led-green' : ''}`}
|
||||
style={!on ? { background: 'var(--color-text-dim)', boxShadow: 'none' } : undefined}
|
||||
/>
|
||||
<span className="font-mono text-xs uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] opacity-70">
|
||||
{on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mt-1">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExperiencesSchema, type Experiences } from '../../../content/schemas/experiences';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Experiences };
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'cdi', label: 'CDI' },
|
||||
{ value: 'cdd', label: 'CDD' },
|
||||
{ value: 'stage', label: 'Stage' },
|
||||
{ value: 'alternance', label: 'Alternance' },
|
||||
{ value: 'freelance', label: 'Freelance' },
|
||||
];
|
||||
|
||||
export function ExperiencesForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Experiences>
|
||||
section="experiences"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ExperiencesSchema) as never}
|
||||
>
|
||||
<Section title="Expériences" subtitle="// setlist">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Postes"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
company: '',
|
||||
role: '',
|
||||
period: '',
|
||||
duration: '',
|
||||
description: '',
|
||||
missions: [],
|
||||
stack: [],
|
||||
type: 'cdi' as const,
|
||||
current: false,
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `track #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.company`} label="Entreprise" />
|
||||
<TextField name={`${namePrefix}.role`} label="Rôle" />
|
||||
</div>
|
||||
<ImageUploadField name={`${namePrefix}.logo`} label="Logo" folder="company" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 items-end">
|
||||
<TextField name={`${namePrefix}.period`} label="Période" placeholder="Janv 2020 — Now" />
|
||||
<TextField name={`${namePrefix}.duration`} label="Durée" placeholder="4 ans" />
|
||||
<SelectField name={`${namePrefix}.type`} label="Type" options={TYPE_OPTIONS} />
|
||||
</div>
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
<ChipsField name={`${namePrefix}.missions`} label="Missions" hint="Une par chip" />
|
||||
<ChipsField name={`${namePrefix}.stack`} label="Stack" hint="Technos utilisées" />
|
||||
<ToggleField name={`${namePrefix}.current`} label="Poste en cours (NOW PLAYING)" />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
|
||||
<Section title="Compétences globales" subtitle="// patch bay">
|
||||
<ChipsField name="skills.technical" label="Techniques" />
|
||||
<ChipsField name="skills.soft" label="Transverses / soft" />
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormationsSchema, type Formations } from '../../../content/schemas/formations';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Formations };
|
||||
|
||||
export function FormationsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Formations>
|
||||
section="formations"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(FormationsSchema) as never}
|
||||
>
|
||||
<Section title="Formations" subtitle="// edu system">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Cursus"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
title: '',
|
||||
school: '',
|
||||
period: '',
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `ticket #${String(i + 1).padStart(3, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.period`} label="Période" placeholder="2018 — 2020" />
|
||||
</div>
|
||||
<TextField name={`${namePrefix}.title`} label="Titre / diplôme" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.school`} label="École" />
|
||||
<TextField
|
||||
name={`${namePrefix}.schoolUrl`}
|
||||
label="Site école"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { InterestsSchema, type Interests } from '../../../content/schemas/interests';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { EmojiField } from '../fields/EmojiField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Interests };
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'cyan', label: '◉ cyan' },
|
||||
{ value: 'magenta', label: '◉ magenta' },
|
||||
{ value: 'violet', label: '◉ violet' },
|
||||
{ value: 'ice', label: '◉ ice' },
|
||||
];
|
||||
|
||||
export function InterestsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Interests>
|
||||
section="interests"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(InterestsSchema) as never}
|
||||
>
|
||||
<Section title="Centres d'intérêt" subtitle="// off decks">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Items"
|
||||
newItem={() =>
|
||||
({ id: '', label: '', icon: '🎧', color: 'cyan' as const }) as never
|
||||
}
|
||||
itemLabel={(i) => `pad #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<EmojiField name={`${namePrefix}.icon`} label="Icône" />
|
||||
<SelectField name={`${namePrefix}.color`} label="Couleur" options={COLOR_OPTIONS} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ProfileSchema, type Profile } from '../../../content/schemas/profile';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { FileUploadField } from '../fields/FileUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Profile };
|
||||
|
||||
export function ProfileForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Profile>
|
||||
section="profile"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ProfileSchema) as never}
|
||||
>
|
||||
<Section title="Identité" subtitle="// owner">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name="name" label="Nom" />
|
||||
<TextField name="title" label="Titre" />
|
||||
</div>
|
||||
<TextField name="tagline" label="Tagline" hint="Phrase courte affichée sous le nom" />
|
||||
<TextAreaField name="bio" label="Bio" rows={5} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||
<ToggleField name="available" label="Disponible" hint="Affiche le badge ON AIR" />
|
||||
<TextField name="availableLabel" label="Texte du badge" hint="ex : Disponible — Nantes" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Hero" subtitle="// stage">
|
||||
<ImageUploadField name="hero.photo" label="Photo principale" folder="pp" />
|
||||
<ChipsField
|
||||
name="hero.roles"
|
||||
label="Rôles affichés"
|
||||
hint="Mots-clés morphés dans le hero (ex : dev, DJ, dev)"
|
||||
/>
|
||||
<ArrayEditor
|
||||
name="hero.photos"
|
||||
label="Galerie hero"
|
||||
hint="Photos additionnelles (slideshow / mosaïque)"
|
||||
newItem={() => ''}
|
||||
itemLabel={(i) => `photo #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<ImageUploadField name={namePrefix} label="URL" folder="pp" />
|
||||
)}
|
||||
</ArrayEditor>
|
||||
<ArrayEditor
|
||||
name="hero.cta"
|
||||
label="Boutons CTA"
|
||||
hint="Actions principales sous le hero"
|
||||
newItem={() => ({ label: '', href: '', variant: 'primary' as const, external: false })}
|
||||
itemLabel={(i) => `cta #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||
<TextField name={`${namePrefix}.href`} label="Lien" placeholder="/projets ou https://…" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||
<SelectField
|
||||
name={`${namePrefix}.variant`}
|
||||
label="Style"
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary (acid)' },
|
||||
{ value: 'ghost', label: 'Ghost (cyan)' },
|
||||
]}
|
||||
/>
|
||||
<ToggleField
|
||||
name={`${namePrefix}.external`}
|
||||
label="Externe (target=_blank)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
|
||||
<Section title="Réseaux" subtitle="// patch bay">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name="socials.email" label="Email" type="email" placeholder="hello@example.com" />
|
||||
<TextField name="socials.github" label="GitHub" type="url" placeholder="https://github.com/…" />
|
||||
<TextField name="socials.gitea" label="Gitea" type="url" />
|
||||
<TextField name="socials.linkedin" label="LinkedIn" type="url" />
|
||||
<TextField name="socials.website" label="Site personnel" type="url" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="CV" subtitle="// dossier">
|
||||
<FileUploadField
|
||||
name="cv"
|
||||
label="Fichier PDF du CV"
|
||||
folder="cv"
|
||||
kind="document"
|
||||
hint="Glisser-déposer un PDF (max 8 Mo)"
|
||||
/>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ProjectsSchema, type Projects } from '../../../content/schemas/projects';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Projects };
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'web', label: 'Web' },
|
||||
{ value: 'mobile', label: 'Mobile' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'infra', label: 'Infra' },
|
||||
{ value: 'other', label: 'Autre' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'live', label: 'Live' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'wip', label: 'WIP' },
|
||||
];
|
||||
|
||||
export function ProjectsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Projects>
|
||||
section="projects"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ProjectsSchema) as never}
|
||||
>
|
||||
<Section title="Projets" subtitle="// crate">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Projets"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'web' as const,
|
||||
status: 'live' as const,
|
||||
stack: [],
|
||||
featured: false,
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `skeud #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.title`} label="Titre" />
|
||||
</div>
|
||||
<TextField name={`${namePrefix}.subtitle`} label="Sous-titre" />
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
<ImageUploadField name={`${namePrefix}.image`} label="Pochette" folder="projet" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<SelectField name={`${namePrefix}.category`} label="Catégorie" options={CATEGORY_OPTIONS} />
|
||||
<SelectField name={`${namePrefix}.status`} label="Statut" options={STATUS_OPTIONS} />
|
||||
<ToggleField name={`${namePrefix}.featured`} label="Mis en avant" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.url`} label="URL live" type="url" />
|
||||
<TextField name={`${namePrefix}.repoUrl`} label="URL repo" type="url" />
|
||||
</div>
|
||||
<ChipsField name={`${namePrefix}.stack`} label="Stack" />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SiteSchema, type Site } from '../../../content/schemas/site';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
|
||||
type Props = { defaultValues: Site };
|
||||
|
||||
export function SiteForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Site>
|
||||
section="site"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(SiteSchema) as never}
|
||||
>
|
||||
<Section title="Méta" subtitle="// SEO">
|
||||
<TextField name="title" label="Titre du site" />
|
||||
<TextAreaField name="description" label="Description" rows={3} />
|
||||
<TextField name="url" label="URL canonique" type="url" />
|
||||
<ChipsField name="keywords" label="Keywords" hint="Mots-clés SEO" />
|
||||
<ImageUploadField name="ogImage" label="OG image" folder="" hint="Affichée lors d'un partage social" />
|
||||
<TextField name="twitter" label="Handle Twitter / X" placeholder="@johanleroy" />
|
||||
</Section>
|
||||
|
||||
<Section title="Features" subtitle="// flags">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<ToggleField name="features.hero3D" label="Hero 3D" />
|
||||
<ToggleField name="features.smoothScroll" label="Smooth scroll (Lenis)" />
|
||||
<ToggleField name="features.customCursor" label="Curseur custom" />
|
||||
<ToggleField name="features.partyMode" label="Party mode (Konami)" />
|
||||
</div>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SkillsSchema, type Skills } from '../../../content/schemas/skills';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { EmojiField } from '../fields/EmojiField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Skills };
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
];
|
||||
|
||||
function CategoryArray({ root, label, subtitle }: { root: 'primary' | 'secondary'; label: string; subtitle?: string }) {
|
||||
return (
|
||||
<Section title={label} subtitle={subtitle}>
|
||||
<ArrayEditor
|
||||
name={root}
|
||||
label="Catégories"
|
||||
newItem={() => ({ id: '', label: '', items: [] as { name: string; icon: string; level: 'primary' | 'secondary' }[] })}
|
||||
itemLabel={(i) => `catégorie #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug interne, ex : front, devops" />
|
||||
<TextField name={`${namePrefix}.label`} label="Label affiché" />
|
||||
</div>
|
||||
<ArrayEditor
|
||||
name={`${namePrefix}.items`}
|
||||
label="Skills"
|
||||
newItem={() => ({ name: '', icon: '⚡', level: 'primary' as const })}
|
||||
itemLabel={(i) => `skill #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix: skillName }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${skillName}.name`} label="Nom" />
|
||||
<SelectField
|
||||
name={`${skillName}.level`}
|
||||
label="Niveau"
|
||||
options={LEVEL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<EmojiField
|
||||
name={`${skillName}.icon`}
|
||||
label="Icône"
|
||||
hint="Emoji ou caractère court (slug iconify supporté)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Skills>
|
||||
section="skills"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(SkillsSchema) as never}
|
||||
>
|
||||
<CategoryArray root="primary" label="Stack primaire" subtitle="// rack 1" />
|
||||
<CategoryArray root="secondary" label="Stack secondaire" subtitle="// rack 2" />
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
const LINES = [
|
||||
{ label: '> init.sound-system', status: 'OK', delay: 150 },
|
||||
{ label: '> load.frequency: 174Hz', status: 'LOCKED', delay: 300 },
|
||||
{ label: '> warmup.amps', status: 'OK', delay: 420 },
|
||||
{ label: '> patch.crate → decks', status: 'READY', delay: 560 },
|
||||
{ label: '> signal.online', status: 'ON AIR', delay: 720 },
|
||||
];
|
||||
|
||||
export default function BootSequence() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (sessionStorage.getItem('portfolio:booted') === '1') return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
sessionStorage.setItem('portfolio:booted', '1');
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
|
||||
const timers: number[] = [];
|
||||
LINES.forEach((line, i) => {
|
||||
timers.push(window.setTimeout(() => setStep(i + 1), line.delay));
|
||||
});
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
sessionStorage.setItem('portfolio:booted', '1');
|
||||
}, 1200),
|
||||
);
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function skip() {
|
||||
sessionStorage.setItem('portfolio:booted', '1');
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center cursor-pointer"
|
||||
onClick={skip}
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, filter: 'blur(12px)' }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ background: 'var(--color-bg)' }}
|
||||
>
|
||||
<div className="font-mono text-xs md:text-sm max-w-sm w-full px-6">
|
||||
<div className="mb-6 flex items-center gap-2 text-[color:var(--color-acid)] uppercase tracking-[0.3em]">
|
||||
<span className="led led-acid animate-pulse" />
|
||||
<span>Booting sound system</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{LINES.slice(0, step).map((line, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className="text-[color:var(--color-text-muted)]">{line.label}</span>
|
||||
<span
|
||||
className={
|
||||
line.status === 'ON AIR'
|
||||
? 'text-[color:var(--color-acid)]'
|
||||
: 'text-[color:var(--color-cyan)]'
|
||||
}
|
||||
>
|
||||
[{line.status}]
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
{step < LINES.length && (
|
||||
<li className="flex items-center gap-1 text-[color:var(--color-text-dim)]">
|
||||
<span className="animate-pulse">█</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<p className="mt-8 text-[10px] text-[color:var(--color-text-dim)] text-center uppercase tracking-widest">
|
||||
click to skip
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import SkeudCard from './SkeudCard';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
category: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
stack: string[];
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export default function Crate({ projects }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!container || !track) return;
|
||||
// Mobile / touch : skip pinned logic, default vertical grid via CSS
|
||||
if (window.matchMedia('(max-width: 767px)').matches) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const getTotalScroll = () => track.scrollWidth - window.innerWidth + 100;
|
||||
|
||||
function onScroll() {
|
||||
if (!container || !track) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const totalScroll = getTotalScroll();
|
||||
if (totalScroll <= 0) return;
|
||||
|
||||
if (rect.top <= 0 && rect.bottom > window.innerHeight) {
|
||||
const progress = Math.min(1, Math.max(0, -rect.top / (container.offsetHeight - window.innerHeight)));
|
||||
track.style.transform = `translate3d(${-progress * totalScroll}px, 0, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll);
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, [projects.length]);
|
||||
|
||||
// Height of container = 100vh * (project count factor) to give scroll room
|
||||
const containerHeightStyle = {
|
||||
height: `${Math.max(100, projects.length * 60)}vh`,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop : pinned horizontal scroll */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="hidden md:block relative"
|
||||
style={containerHeightStyle}
|
||||
>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="sticky top-16 h-screen overflow-hidden flex items-center"
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex items-center gap-10 pl-[10vw] pr-[20vw] will-change-transform"
|
||||
>
|
||||
{projects.map((project, i) => (
|
||||
<SkeudCard key={project.id} project={project} index={i} />
|
||||
))}
|
||||
<div
|
||||
className="flex-shrink-0 font-mono text-xs text-[color:var(--color-text-dim)] uppercase tracking-widest writing-mode-vertical"
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
end of crate · {projects.length} skeuds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile : vertical grid fallback */}
|
||||
<div className="md:hidden grid grid-cols-1 gap-10 pt-8">
|
||||
{projects.map((project, i) => (
|
||||
<SkeudCard key={project.id} project={project} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const KONAMI = [
|
||||
'ArrowUp',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'KeyB',
|
||||
'KeyA',
|
||||
];
|
||||
|
||||
function triggerPartyMode() {
|
||||
if (document.getElementById('party-overlay')) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'party-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;z-index:9999;pointer-events:none;background:transparent;';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Strobe flashes
|
||||
const strobe = document.createElement('div');
|
||||
strobe.style.cssText =
|
||||
'position:absolute;inset:0;background:#ffffff;opacity:0;mix-blend-mode:difference;';
|
||||
overlay.appendChild(strobe);
|
||||
|
||||
const colors = ['#00F0FF', '#FF2A6D', '#C6FF00', '#B967FF'];
|
||||
let i = 0;
|
||||
const strobeInterval = setInterval(() => {
|
||||
const c = colors[i++ % colors.length];
|
||||
strobe.style.background = c;
|
||||
strobe.style.opacity = '0.4';
|
||||
setTimeout(() => (strobe.style.opacity = '0'), 60);
|
||||
}, 140);
|
||||
|
||||
// Big text
|
||||
const text = document.createElement('div');
|
||||
text.textContent = 'PARTY MODE ACTIVATED';
|
||||
text.style.cssText = `
|
||||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||||
font-family:"Bebas Neue",Impact,sans-serif;font-size:min(12vw,8rem);
|
||||
color:#C6FF00;text-shadow:0 0 30px #C6FF00;letter-spacing:0.1em;
|
||||
animation:glitch-skew 0.2s infinite;pointer-events:none;
|
||||
`;
|
||||
overlay.appendChild(text);
|
||||
|
||||
// Clean up after 4 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(strobeInterval);
|
||||
overlay.style.transition = 'opacity 0.5s';
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => overlay.remove(), 500);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function playTick() {
|
||||
const soundOn = (window as unknown as { __soundOn?: boolean }).__soundOn;
|
||||
if (!soundOn) return;
|
||||
try {
|
||||
const AudioCtx =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const ctx = new AudioCtx();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = 1200;
|
||||
gain.gain.value = 0.02;
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.04);
|
||||
osc.stop(ctx.currentTime + 0.05);
|
||||
} catch {
|
||||
/* audio disabled */
|
||||
}
|
||||
}
|
||||
|
||||
export default function EasterEggs() {
|
||||
useEffect(() => {
|
||||
// Konami code
|
||||
let buffer: string[] = [];
|
||||
function onKey(e: KeyboardEvent) {
|
||||
buffer.push(e.code);
|
||||
if (buffer.length > KONAMI.length) buffer.shift();
|
||||
if (KONAMI.every((k, i) => buffer[i] === k)) {
|
||||
triggerPartyMode();
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
// Logo 5x click
|
||||
let logoClickCount = 0;
|
||||
let logoTimer: number | null = null;
|
||||
const logo = document.querySelector<HTMLElement>('header a[href="/"]');
|
||||
function onLogoClick(e: Event) {
|
||||
logoClickCount += 1;
|
||||
if (logoClickCount >= 5) {
|
||||
e.preventDefault();
|
||||
logoClickCount = 0;
|
||||
triggerPartyMode();
|
||||
}
|
||||
if (logoTimer) window.clearTimeout(logoTimer);
|
||||
logoTimer = window.setTimeout(() => {
|
||||
logoClickCount = 0;
|
||||
}, 2000);
|
||||
}
|
||||
logo?.addEventListener('click', onLogoClick);
|
||||
|
||||
// Tick on hover links/buttons
|
||||
function onMouseOver(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (!t?.closest) return;
|
||||
if (t.closest('a, button, [role="button"]')) {
|
||||
playTick();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mouseover', onMouseOver, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
logo?.removeEventListener('click', onLogoClick);
|
||||
document.removeEventListener('mouseover', onMouseOver);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useRef, type ReactNode, type MouseEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
strength?: number;
|
||||
download?: boolean | string;
|
||||
}
|
||||
|
||||
export default function MagneticButton({
|
||||
children,
|
||||
href,
|
||||
onClick,
|
||||
className = '',
|
||||
target,
|
||||
rel,
|
||||
strength = 0.3,
|
||||
download,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - (rect.left + rect.width / 2);
|
||||
const y = e.clientY - (rect.top + rect.height / 2);
|
||||
el.style.transform = `translate(${x * strength}px, ${y * strength}px)`;
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
if (!ref.current) return;
|
||||
ref.current.style.transform = '';
|
||||
}
|
||||
|
||||
const baseClass = `inline-block transition-transform duration-200 ease-out will-change-transform ${className}`;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
download={download as boolean | string | undefined}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={handleLeave}
|
||||
className={baseClass}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.RefObject<HTMLButtonElement>}
|
||||
type="button"
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={handleLeave}
|
||||
onClick={onClick}
|
||||
className={baseClass}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
technical: string[];
|
||||
soft: string[];
|
||||
}
|
||||
|
||||
export default function PatchBay({ technical, soft }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const svg = svgRef.current;
|
||||
if (!container || !svg) return;
|
||||
|
||||
function drawCables() {
|
||||
if (!svg || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
|
||||
// Find left + right jack positions
|
||||
const leftJacks = container.querySelectorAll<HTMLElement>('[data-jack="left"]');
|
||||
const rightJacks = container.querySelectorAll<HTMLElement>('[data-jack="right"]');
|
||||
const cr = container.getBoundingClientRect();
|
||||
|
||||
const leftPoints = Array.from(leftJacks).map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.right - cr.left, y: r.top + r.height / 2 - cr.top };
|
||||
});
|
||||
const rightPoints = Array.from(rightJacks).map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left - cr.left, y: r.top + r.height / 2 - cr.top };
|
||||
});
|
||||
|
||||
// Build cable paths pairing left to right
|
||||
const colors = ['var(--color-cyan)', 'var(--color-magenta)', 'var(--color-acid)'];
|
||||
svg.innerHTML = '';
|
||||
leftPoints.forEach((p1, i) => {
|
||||
const pairIdx = i % rightPoints.length;
|
||||
const p2 = rightPoints[pairIdx];
|
||||
if (!p2) return;
|
||||
const dx = p2.x - p1.x;
|
||||
const sag = 20 + (i % 3) * 8;
|
||||
const midY = (p1.y + p2.y) / 2 + sag;
|
||||
const c1x = p1.x + dx * 0.4;
|
||||
const c2x = p1.x + dx * 0.6;
|
||||
const d = `M ${p1.x} ${p1.y} C ${c1x} ${midY}, ${c2x} ${midY}, ${p2.x} ${p2.y}`;
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', colors[i % colors.length]);
|
||||
path.setAttribute('stroke-width', '1.6');
|
||||
path.setAttribute('stroke-opacity', '0.55');
|
||||
path.style.filter = `drop-shadow(0 0 4px ${colors[i % colors.length]})`;
|
||||
svg.appendChild(path);
|
||||
|
||||
// Plug tips
|
||||
[p1, p2].forEach((p) => {
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(p.x));
|
||||
circle.setAttribute('cy', String(p.y));
|
||||
circle.setAttribute('r', '3');
|
||||
circle.setAttribute('fill', colors[i % colors.length]);
|
||||
circle.style.filter = `drop-shadow(0 0 6px ${colors[i % colors.length]})`;
|
||||
svg.appendChild(circle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
drawCables();
|
||||
const ro = new ResizeObserver(drawCables);
|
||||
ro.observe(container);
|
||||
window.addEventListener('resize', drawCables);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', drawCables);
|
||||
};
|
||||
}, [technical.length, soft.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative grid grid-cols-1 sm:grid-cols-2 md:grid-cols-[1fr_auto_1fr] gap-6 sm:gap-10 md:gap-20 bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm p-5 sm:p-6 md:p-10"
|
||||
>
|
||||
{/* Left panel : technical */}
|
||||
<div>
|
||||
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-cyan)] glow-text-cyan mb-4">
|
||||
Techniques
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{technical.map((s) => (
|
||||
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||
<span
|
||||
data-jack="left"
|
||||
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-cyan)] bg-[color:var(--color-bg)]"
|
||||
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Middle : SVG cables */}
|
||||
<div className="hidden md:block relative min-h-full">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel : soft */}
|
||||
<div>
|
||||
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] glow-text-magenta mb-4">
|
||||
Transverses
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{soft.map((s) => (
|
||||
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||
<span
|
||||
data-jack="right"
|
||||
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-magenta)] bg-[color:var(--color-bg)]"
|
||||
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
||||
/>
|
||||
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Icon } from '@iconify-icon/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
icon: string;
|
||||
level: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
items: Item[];
|
||||
accent?: 'cyan' | 'acid' | 'magenta';
|
||||
bpm?: number;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const accentMap = {
|
||||
cyan: 'var(--color-cyan)',
|
||||
acid: 'var(--color-acid)',
|
||||
magenta: 'var(--color-magenta)',
|
||||
};
|
||||
|
||||
export default function RackModule({
|
||||
label,
|
||||
items,
|
||||
accent = 'cyan',
|
||||
bpm = 174,
|
||||
index = 0,
|
||||
}: Props) {
|
||||
const color = accentMap[accent];
|
||||
const [levels, setLevels] = useState<number[]>([]);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setLevels([0.6, 0.5, 0.7, 0.4]);
|
||||
return;
|
||||
}
|
||||
const start = performance.now() + index * 137;
|
||||
const tick = (now: number) => {
|
||||
const t = (now - start) / 1000;
|
||||
setLevels([
|
||||
0.5 + 0.3 * Math.abs(Math.sin(t * 2.1)),
|
||||
0.4 + 0.35 * Math.abs(Math.sin(t * 2.8 + 1)),
|
||||
0.6 + 0.25 * Math.abs(Math.sin(t * 1.5 + 2)),
|
||||
0.3 + 0.4 * Math.abs(Math.sin(t * 3.2 + 0.5)),
|
||||
]);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [index]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="relative bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm overflow-hidden"
|
||||
style={{ boxShadow: `inset 0 1px 0 rgba(255,255,255,0.03)` }}
|
||||
>
|
||||
{/* Top bar : LED + label + BPM */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-[color:var(--color-border)] bg-[color:var(--color-surface-2)]"
|
||||
style={{ boxShadow: `inset 0 -1px 0 ${color}22` }}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="led"
|
||||
style={{ background: color, boxShadow: `0 0 8px ${color}` }}
|
||||
/>
|
||||
<span
|
||||
className="font-stencil text-sm uppercase tracking-[0.25em]"
|
||||
style={{ color, textShadow: `0 0 10px ${color}80` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
[{bpm} bpm]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body : VU meter (vertical bars) + pads grid */}
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* VU meter */}
|
||||
<div
|
||||
className="flex flex-col gap-1 w-3 pt-1"
|
||||
aria-hidden="true"
|
||||
style={{ justifyContent: 'flex-end' }}
|
||||
>
|
||||
{[0.9, 0.75, 0.6, 0.45, 0.3, 0.15].map((threshold, i) => {
|
||||
const on = (levels[i % 4] ?? 0) > threshold;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="block w-full h-1 rounded-[1px] transition-opacity"
|
||||
style={{
|
||||
background: on ? color : `${color}20`,
|
||||
boxShadow: on ? `0 0 6px ${color}` : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pads grid */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 flex-1 content-start">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className="group relative aspect-square flex flex-col items-center justify-center gap-1 bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm transition-all hover:translate-y-px active:translate-y-0.5"
|
||||
style={{
|
||||
boxShadow: `inset 0 -2px 0 ${color}15`,
|
||||
}}
|
||||
title={item.name}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget.style.borderColor = `${color}aa`);
|
||||
(e.currentTarget.style.boxShadow = `0 0 20px ${color}44, inset 0 -2px 0 ${color}66`);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget.style.borderColor = '');
|
||||
(e.currentTarget.style.boxShadow = `inset 0 -2px 0 ${color}15`);
|
||||
}}
|
||||
>
|
||||
<Icon icon={item.icon} width="20" height="20" />
|
||||
<span className="text-[9px] font-mono uppercase tracking-wider text-[color:var(--color-text-muted)] group-hover:text-[color:var(--color-text)] transition-colors truncate max-w-full px-1">
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className="absolute top-1 right-1 w-1 h-1 rounded-full opacity-30 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: color, boxShadow: `0 0 4px ${color}` }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar : screws / decoration */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-t border-[color:var(--color-border)] bg-[color:var(--color-surface-2)]">
|
||||
<span className="font-mono text-[9px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||
CH_{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{[0, 1].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-[color:var(--color-border)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
direction?: 'up' | 'down' | 'left' | 'right' | 'none';
|
||||
once?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
up: { y: 30, opacity: 0, filter: 'blur(8px)' },
|
||||
down: { y: -30, opacity: 0, filter: 'blur(8px)' },
|
||||
left: { x: -30, opacity: 0, filter: 'blur(8px)' },
|
||||
right: { x: 30, opacity: 0, filter: 'blur(8px)' },
|
||||
none: { opacity: 0, filter: 'blur(8px)' },
|
||||
};
|
||||
|
||||
export default function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
direction = 'up',
|
||||
once = true,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inView = useInView(ref, { once, margin: '-50px' });
|
||||
const initial = variants[direction];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
initial={initial}
|
||||
animate={inView ? { x: 0, y: 0, opacity: 1, filter: 'blur(0px)' } : initial}
|
||||
transition={{ duration: 0.8, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
roles: string[];
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export default function RolesMorph({ roles, interval = 2600 }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [glitch, setGlitch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length <= 1) return;
|
||||
const timer = setInterval(() => {
|
||||
setGlitch(true);
|
||||
setTimeout(() => {
|
||||
setIdx((i) => (i + 1) % roles.length);
|
||||
setGlitch(false);
|
||||
}, 180);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [roles, interval]);
|
||||
|
||||
const current = roles[idx] ?? '';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block font-display text-[color:var(--color-cyan)] glow-text-cyan transition-all duration-200 ${
|
||||
glitch ? 'blur-[1px] opacity-60 skew-x-3' : ''
|
||||
}`}
|
||||
data-text={current}
|
||||
>
|
||||
{current}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
logo?: string;
|
||||
role: string;
|
||||
period: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
missions: string[];
|
||||
stack: string[];
|
||||
type: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: Experience[];
|
||||
}
|
||||
|
||||
function WaveformBar({ level }: { level: number }) {
|
||||
// ASCII-like progress bar with blocks
|
||||
const total = 12;
|
||||
const filled = Math.round(level * total);
|
||||
return (
|
||||
<span className="font-mono tracking-tighter" aria-hidden="true">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
opacity: i < filled ? 1 : 0.25,
|
||||
color: i < filled ? 'var(--color-cyan)' : 'var(--color-text-dim)',
|
||||
}}
|
||||
>
|
||||
█
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Setlist({ items }: Props) {
|
||||
const [open, setOpen] = useState<string | null>(items[0]?.id ?? null);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[color:var(--color-border)] border-y border-[color:var(--color-border)]">
|
||||
{items.map((exp, i) => {
|
||||
const isOpen = open === exp.id;
|
||||
const bpm = exp.current ? 174 : 160 - i * 10;
|
||||
const level = exp.current ? 1 : Math.max(0.2, 1 - i * 0.15);
|
||||
return (
|
||||
<article key={exp.id} className={exp.current ? 'bg-[rgba(198,255,0,0.03)]' : ''}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : exp.id)}
|
||||
className="w-full flex items-center gap-3 md:gap-6 px-3 md:px-5 py-4 text-left group hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{/* Track number */}
|
||||
<span className="font-mono text-xs md:text-sm text-[color:var(--color-text-dim)] w-8 tabular-nums">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{/* Status tag */}
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest w-28"
|
||||
style={{
|
||||
color: exp.current ? 'var(--color-red)' : 'var(--color-text-dim)',
|
||||
}}
|
||||
>
|
||||
{exp.current ? (
|
||||
<>
|
||||
<span className="led led-red animate-led-red"></span>
|
||||
[now playing]
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-60">—</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Company + role */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-display text-sm md:text-base font-semibold truncate group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||||
{exp.company}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] md:text-xs text-[color:var(--color-text-muted)] uppercase tracking-wider truncate">
|
||||
{exp.role} · {exp.period}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform progress */}
|
||||
<div className="hidden md:block">
|
||||
<WaveformBar level={level} />
|
||||
</div>
|
||||
|
||||
{/* BPM / duration */}
|
||||
<div className="hidden sm:flex flex-col items-end gap-0.5 font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] w-24 shrink-0">
|
||||
<span>[{bpm} bpm]</span>
|
||||
<span>[{exp.duration}]</span>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<span
|
||||
className={`font-mono text-[color:var(--color-cyan)] transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 md:px-5 pb-6 pt-2 grid grid-cols-1 sm:grid-cols-[180px_1fr] md:grid-cols-[200px_1fr] gap-4 sm:gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
{exp.logo && (
|
||||
<div className="w-16 h-16 shrink-0 flex items-center justify-center bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm p-2">
|
||||
<img
|
||||
src={exp.logo}
|
||||
alt={exp.company}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] space-y-1">
|
||||
<div>[type: {exp.type}]</div>
|
||||
<div>[duration: {exp.duration}]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-[color:var(--color-text)] mb-4">{exp.description}</p>
|
||||
|
||||
{exp.missions.length > 0 && (
|
||||
<ul className="space-y-1.5 mb-4">
|
||||
{exp.missions.map((m, idx) => (
|
||||
<li key={idx} className="flex gap-2 text-sm">
|
||||
<span className="font-mono text-[color:var(--color-acid)] shrink-0">›</span>
|
||||
<span className="text-[color:var(--color-text-muted)]">{m}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{exp.stack.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{exp.stack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="font-mono text-[10px] uppercase tracking-wider px-2 py-1 text-[color:var(--color-cyan)] border border-[color:var(--color-cyan)]/30 bg-[color:var(--color-cyan)]/5 rounded-sm"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
category: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
stack: string[];
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
index: number;
|
||||
bpm?: number;
|
||||
}
|
||||
|
||||
function statusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return { label: 'LIVE', color: 'var(--color-acid)' };
|
||||
case 'archived':
|
||||
return { label: 'ARCHIVED', color: 'var(--color-text-dim)' };
|
||||
case 'private':
|
||||
return { label: 'DUBPLATE', color: 'var(--color-magenta)' };
|
||||
case 'wip':
|
||||
return { label: 'WIP', color: 'var(--color-violet)' };
|
||||
default:
|
||||
return { label: status.toUpperCase(), color: 'var(--color-cyan)' };
|
||||
}
|
||||
}
|
||||
|
||||
export default function SkeudCard({ project, index, bpm }: Props) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const status = statusInfo(project.status);
|
||||
const bpmValue = bpm ?? 120 + ((index * 13) % 80);
|
||||
const isArchived = project.status === 'archived';
|
||||
const isPrivate = project.status === 'private';
|
||||
const clickable = Boolean(project.url) && !isArchived && !isPrivate;
|
||||
const orderLabel = `#${String(index + 1).padStart(2, '0')}`;
|
||||
|
||||
const Wrap = clickable ? 'a' : 'div';
|
||||
const wrapProps = clickable
|
||||
? { href: project.url, target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Wrap
|
||||
{...wrapProps}
|
||||
className="group relative block w-[260px] sm:w-[300px] md:w-[340px] flex-shrink-0"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ perspective: 1200 } as CSSProperties}
|
||||
>
|
||||
{/* Order number en filigrane derrière */}
|
||||
<span
|
||||
className="absolute -top-8 sm:-top-10 -left-2 stencil text-5xl sm:text-7xl leading-none text-[color:var(--color-border)] select-none pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{orderLabel}
|
||||
</span>
|
||||
|
||||
{/* Cover / pochette */}
|
||||
<div
|
||||
className="relative aspect-square bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm overflow-hidden transition-all duration-500"
|
||||
style={{
|
||||
transform: hover ? 'rotateY(-3deg) rotateX(2deg)' : 'rotateY(0) rotateX(0)',
|
||||
transformStyle: 'preserve-3d',
|
||||
boxShadow: hover
|
||||
? '0 30px 60px -20px rgba(0, 240, 255, 0.25), 0 0 1px rgba(0, 240, 255, 0.4)'
|
||||
: '0 10px 30px -10px rgba(0,0,0,0.5)',
|
||||
filter: isArchived ? 'grayscale(0.7)' : 'none',
|
||||
}}
|
||||
>
|
||||
{project.image ? (
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
transform: hover ? 'scale(1.04)' : 'scale(1)',
|
||||
transition: 'transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4 warehouse-bg">
|
||||
<span className="stencil text-4xl uppercase text-[color:var(--color-text-muted)]">
|
||||
{project.title.slice(0, 1)}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
// no cover
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stickers overlay */}
|
||||
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
|
||||
<span
|
||||
className="sticker"
|
||||
style={{ color: status.color, transform: 'rotate(-3deg)' }}
|
||||
>
|
||||
<span
|
||||
className="led mr-1.5"
|
||||
style={{ background: status.color, boxShadow: `0 0 6px ${status.color}` }}
|
||||
/>
|
||||
{status.label}
|
||||
</span>
|
||||
<span
|
||||
className="sticker text-[color:var(--color-cyan)]"
|
||||
style={{ transform: 'rotate(1deg)' }}
|
||||
>
|
||||
{bpmValue} BPM
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scanlines overlay */}
|
||||
<div className="absolute inset-0 scanlines opacity-40 pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
{/* Vinyle qui sort par la droite */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 w-[88%] aspect-square pointer-events-none transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: hover
|
||||
? 'translate(42%, 0) rotate(360deg)'
|
||||
: 'translate(6%, 0) rotate(0)',
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: hover ? '1.4s' : '0.5s',
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center, transparent 18%, #050508 19%, #0E0E14 55%, #050508 85%, #00000000 100%)',
|
||||
boxShadow:
|
||||
'0 0 0 1px rgba(255,255,255,0.04) inset, 0 15px 40px -8px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
>
|
||||
{/* Grooves */}
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{[40, 35, 30, 25, 20].map((r, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.04)"
|
||||
strokeWidth="0.4"
|
||||
/>
|
||||
))}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="11"
|
||||
fill={hover ? 'var(--color-acid)' : 'var(--color-magenta)'}
|
||||
style={{ transition: 'fill 0.4s' }}
|
||||
/>
|
||||
<circle cx="50" cy="50" r="1" fill="#000" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infos en bas */}
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3
|
||||
className="font-display text-lg font-semibold group-hover:text-[color:var(--color-cyan)] transition-colors"
|
||||
>
|
||||
{project.title}
|
||||
</h3>
|
||||
<span className="font-mono text-[10px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||
[{project.category}]
|
||||
</span>
|
||||
</div>
|
||||
{project.subtitle && (
|
||||
<p className="font-mono text-[10px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[color:var(--color-text-muted)] line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
{project.stack.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{project.stack.slice(0, 4).map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 text-[color:var(--color-text-muted)] border border-[color:var(--color-border)] rounded-sm"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
{project.stack.length > 4 && (
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 text-[color:var(--color-text-dim)]">
|
||||
+{project.stack.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Icon } from '@iconify-icon/react';
|
||||
import { useRef, type MouseEvent } from 'react';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
icon: string;
|
||||
level: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
items: Item[];
|
||||
accent?: 'cyan' | 'magenta';
|
||||
}
|
||||
|
||||
export default function SkillCard({ label, items, accent = 'cyan' }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
function onMove(e: MouseEvent<HTMLDivElement>) {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width - 0.5;
|
||||
const y = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
ref.current.style.transform = `perspective(1000px) rotateX(${-y * 6}deg) rotateY(${x * 6}deg) translateZ(0)`;
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (!ref.current) return;
|
||||
ref.current.style.transform = 'perspective(1000px) rotateX(0) rotateY(0)';
|
||||
}
|
||||
|
||||
const accentColor = accent === 'cyan' ? 'var(--color-cyan)' : 'var(--color-magenta)';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={onLeave}
|
||||
className="card-neon h-full flex flex-col transition-transform duration-300 ease-out will-change-transform"
|
||||
style={{ borderColor: `${accentColor}33` }}
|
||||
>
|
||||
<h3
|
||||
className="font-display uppercase text-xs tracking-[0.2em] mb-5"
|
||||
style={{ color: accentColor, textShadow: `0 0 10px ${accentColor}66` }}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5 flex-1 content-start">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex flex-col items-center text-center group"
|
||||
title={item.name}
|
||||
>
|
||||
<div className="w-11 h-11 flex items-center justify-center rounded-md bg-[color:var(--color-bg)] border border-[color:var(--color-border)] group-hover:border-[color:var(--color-cyan)] transition-all group-hover:glow-cyan">
|
||||
<Icon icon={item.icon} width="22" height="22" />
|
||||
</div>
|
||||
<span className="mt-1.5 text-[10px] font-mono text-[color:var(--color-text-muted)] group-hover:text-[color:var(--color-text)] transition-colors truncate max-w-full">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Lenis from 'lenis';
|
||||
|
||||
export default function SmoothScroll() {
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
let rafId: number;
|
||||
function raf(time: number) {
|
||||
lenis.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
}
|
||||
rafId = requestAnimationFrame(raf);
|
||||
|
||||
const resetScroll = () => {
|
||||
lenis.scrollTo(0, { immediate: true, force: true });
|
||||
};
|
||||
document.addEventListener('astro:after-swap', resetScroll);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('astro:after-swap', resetScroll);
|
||||
cancelAnimationFrame(rafId);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
stagger?: number;
|
||||
as?: 'span' | 'h1' | 'h2' | 'h3' | 'p' | 'div';
|
||||
}
|
||||
|
||||
export default function SplitReveal({
|
||||
text,
|
||||
className = '',
|
||||
delay = 0,
|
||||
stagger = 0.035,
|
||||
as = 'span',
|
||||
}: Props) {
|
||||
const chars = Array.from(text);
|
||||
const MotionTag = motion[as] as typeof motion.span;
|
||||
|
||||
return (
|
||||
<MotionTag
|
||||
className={className}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
aria-label={text}
|
||||
variants={{
|
||||
hidden: {},
|
||||
show: {
|
||||
transition: { staggerChildren: stagger, delayChildren: delay },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{chars.map((ch, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{ display: 'inline-block', whiteSpace: ch === ' ' ? 'pre' : 'normal' }}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: '0.4em', filter: 'blur(8px)' },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{ch === ' ' ? '\u00A0' : ch}
|
||||
</motion.span>
|
||||
))}
|
||||
</MotionTag>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
bars?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Waveform({
|
||||
bars = 64,
|
||||
color = 'var(--color-cyan)',
|
||||
className = '',
|
||||
}: Props) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const barRefs = useRef<(SVGRectElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
let rafId: number;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = (now - start) / 1000;
|
||||
barRefs.current.forEach((bar, i) => {
|
||||
if (!bar) return;
|
||||
// Multi-sine combination → pulse organique
|
||||
const x = i / bars;
|
||||
const amp =
|
||||
0.5 +
|
||||
0.25 * Math.sin(t * 1.7 + x * 8) +
|
||||
0.15 * Math.sin(t * 3.1 + x * 16) +
|
||||
0.1 * Math.sin(t * 0.8 + x * 4);
|
||||
const h = Math.max(0.08, Math.min(1, amp));
|
||||
bar.setAttribute('transform', `scale(1, ${h})`);
|
||||
});
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [bars]);
|
||||
|
||||
const barWidth = 100 / bars;
|
||||
const gap = barWidth * 0.3;
|
||||
const rectWidth = barWidth - gap;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox="0 0 100 20"
|
||||
preserveAspectRatio="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: bars }).map((_, i) => (
|
||||
<g key={i} transform={`translate(${i * barWidth + gap / 2}, 10)`}>
|
||||
<rect
|
||||
ref={(el) => {
|
||||
barRefs.current[i] = el;
|
||||
}}
|
||||
x={0}
|
||||
y={-10}
|
||||
width={rectWidth}
|
||||
height={20}
|
||||
fill={color}
|
||||
style={{
|
||||
transformBox: 'fill-box',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ExperienceSchema = z.object({
|
||||
id: z.string(),
|
||||
company: z.string(),
|
||||
logo: z.string().optional(),
|
||||
role: z.string(),
|
||||
period: z.string(),
|
||||
duration: z.string(),
|
||||
description: z.string(),
|
||||
missions: z.array(z.string()).default([]),
|
||||
stack: z.array(z.string()).default([]),
|
||||
type: z.enum(['cdi', 'cdd', 'stage', 'alternance', 'freelance']).default('cdi'),
|
||||
current: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const ExperiencesSchema = z.object({
|
||||
items: z.array(ExperienceSchema),
|
||||
skills: z
|
||||
.object({
|
||||
technical: z.array(z.string()).default([]),
|
||||
soft: z.array(z.string()).default([]),
|
||||
})
|
||||
.default({ technical: [], soft: [] }),
|
||||
});
|
||||
|
||||
export type Experience = z.infer<typeof ExperienceSchema>;
|
||||
export type Experiences = z.infer<typeof ExperiencesSchema>;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FormationSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
school: z.string(),
|
||||
schoolUrl: z.url().optional(),
|
||||
period: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FormationsSchema = z.object({
|
||||
items: z.array(FormationSchema),
|
||||
});
|
||||
|
||||
export type Formation = z.infer<typeof FormationSchema>;
|
||||
export type Formations = z.infer<typeof FormationsSchema>;
|
||||
@@ -1,27 +0,0 @@
|
||||
export * from './profile';
|
||||
export * from './skills';
|
||||
export * from './experiences';
|
||||
export * from './formations';
|
||||
export * from './projects';
|
||||
export * from './interests';
|
||||
export * from './site';
|
||||
|
||||
import { ProfileSchema } from './profile';
|
||||
import { SkillsSchema } from './skills';
|
||||
import { ExperiencesSchema } from './experiences';
|
||||
import { FormationsSchema } from './formations';
|
||||
import { ProjectsSchema } from './projects';
|
||||
import { InterestsSchema } from './interests';
|
||||
import { SiteSchema } from './site';
|
||||
|
||||
export const CONTENT_SCHEMAS = {
|
||||
profile: ProfileSchema,
|
||||
skills: SkillsSchema,
|
||||
experiences: ExperiencesSchema,
|
||||
formations: FormationsSchema,
|
||||
projects: ProjectsSchema,
|
||||
interests: InterestsSchema,
|
||||
site: SiteSchema,
|
||||
} as const;
|
||||
|
||||
export type ContentKey = keyof typeof CONTENT_SCHEMAS;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const InterestSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
icon: z.string(),
|
||||
color: z.enum(['cyan', 'magenta', 'violet', 'ice']).default('cyan'),
|
||||
});
|
||||
|
||||
export const InterestsSchema = z.object({
|
||||
items: z.array(InterestSchema),
|
||||
});
|
||||
|
||||
export type Interest = z.infer<typeof InterestSchema>;
|
||||
export type Interests = z.infer<typeof InterestsSchema>;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ProfileSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
tagline: z.string(),
|
||||
bio: z.string(),
|
||||
available: z.boolean().default(false),
|
||||
availableLabel: z.string().optional(),
|
||||
hero: z.object({
|
||||
photo: z.string(),
|
||||
photos: z.array(z.string()).default([]),
|
||||
roles: z.array(z.string()).default([]),
|
||||
cta: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
variant: z.enum(['primary', 'ghost']).default('primary'),
|
||||
external: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
socials: z.object({
|
||||
email: z.email().optional(),
|
||||
github: z.url().optional(),
|
||||
gitea: z.url().optional(),
|
||||
linkedin: z.url().optional(),
|
||||
website: z.url().optional(),
|
||||
}),
|
||||
cv: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Profile = z.infer<typeof ProfileSchema>;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
description: z.string(),
|
||||
image: z.string().optional(),
|
||||
category: z.enum(['web', 'mobile', 'bot', 'infra', 'other']).default('web'),
|
||||
status: z.enum(['live', 'archived', 'private', 'wip']).default('live'),
|
||||
url: z.url().optional(),
|
||||
repoUrl: z.url().optional(),
|
||||
stack: z.array(z.string()).default([]),
|
||||
featured: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const ProjectsSchema = z.object({
|
||||
items: z.array(ProjectSchema),
|
||||
});
|
||||
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
export type Projects = z.infer<typeof ProjectsSchema>;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SiteSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
url: z.url(),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
ogImage: z.string(),
|
||||
twitter: z.string().optional(),
|
||||
features: z
|
||||
.object({
|
||||
hero3D: z.boolean().default(true),
|
||||
smoothScroll: z.boolean().default(true),
|
||||
customCursor: z.boolean().default(true),
|
||||
partyMode: z.boolean().default(true),
|
||||
})
|
||||
.default({
|
||||
hero3D: true,
|
||||
smoothScroll: true,
|
||||
customCursor: true,
|
||||
partyMode: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export type Site = z.infer<typeof SiteSchema>;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SkillSchema = z.object({
|
||||
name: z.string(),
|
||||
icon: z.string(),
|
||||
level: z.enum(['primary', 'secondary']).default('primary'),
|
||||
});
|
||||
|
||||
export const SkillCategorySchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
items: z.array(SkillSchema),
|
||||
});
|
||||
|
||||
export const SkillsSchema = z.object({
|
||||
primary: z.array(SkillCategorySchema),
|
||||
secondary: z.array(SkillCategorySchema),
|
||||
});
|
||||
|
||||
export type Skill = z.infer<typeof SkillSchema>;
|
||||
export type SkillCategory = z.infer<typeof SkillCategorySchema>;
|
||||
export type Skills = z.infer<typeof SkillsSchema>;
|
||||
308
src/data/content.ts
Normal file
308
src/data/content.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/* ============================================================
|
||||
Contenu du portfolio — SOURCE DE VÉRITÉ unique.
|
||||
Pour modifier le site (textes, projets, expériences…),
|
||||
c'est ici que ça se passe. Tout est typé.
|
||||
============================================================ */
|
||||
|
||||
export type ProjectStatus = "live" | "archived" | "oss";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
desc: string;
|
||||
stack: string[];
|
||||
status: ProjectStatus;
|
||||
link?: string;
|
||||
img?: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
company: string;
|
||||
logo: string;
|
||||
role: string;
|
||||
period: string;
|
||||
duration: string;
|
||||
desc: string;
|
||||
missions: string[];
|
||||
stack: string[];
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface Formation {
|
||||
title: string;
|
||||
school: string;
|
||||
period: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface StackGroup {
|
||||
label: string;
|
||||
items: string[]; // noms de technos, résolus en icônes par <TechIcon/>
|
||||
}
|
||||
|
||||
export interface Interest {
|
||||
name: string;
|
||||
note: string;
|
||||
icon: "gamepad" | "film" | "music" | "code";
|
||||
}
|
||||
|
||||
export const profile = {
|
||||
name: "Johan Leroy",
|
||||
role: "Développeur fullstack",
|
||||
lede:
|
||||
"Je conçois et développe des applications web performantes, propres et scalables — du front jusqu'à la mise en production. Basé à Nantes, profil « builder » : open-source, auto-hébergement, projets perso.",
|
||||
bio:
|
||||
"Développeur fullstack passionné par les technologies modernes. J'aime construire des produits complets — penser l'archi, soigner l'expérience, déployer et maintenir. J'expérimente en continu via mes projets perso, mes services auto-hébergés et l'open-source.",
|
||||
};
|
||||
|
||||
export const social = {
|
||||
email: "contact@johanleroy.fr",
|
||||
github: "https://github.com/JohanLeroy",
|
||||
gitea: "https://git.lidge.fr/johanleroy",
|
||||
linkedin: "https://www.linkedin.com/in/johan-leroy-472409180/",
|
||||
site: "https://johanleroy.fr",
|
||||
};
|
||||
|
||||
/* URL du bouton « café » — à remplacer par le service retenu
|
||||
(Buy Me a Coffee, Ko-fi, Liberapay, PayPal.me, Stripe…). */
|
||||
export const coffeeUrl = "https://buymeacoffee.com/johanleroy";
|
||||
|
||||
export const stats = [
|
||||
{ n: "~3 ans", l: "d'expérience pro" },
|
||||
{ n: "6+", l: "projets livrés" },
|
||||
{ n: "8+", l: "services auto-hébergés" },
|
||||
{ n: "OSS", l: "contributeur open-source" },
|
||||
];
|
||||
|
||||
export const projectsFeatured: Project[] = [
|
||||
{
|
||||
id: "techos",
|
||||
title: "Techos",
|
||||
subtitle: "Plateforme hébergement & domaines",
|
||||
desc:
|
||||
"Plateforme client + admin de gestion d'hébergement web et de domaines pour une association, bâtie sur WHMCS. Design system « Liquid Glass » clair/sombre.",
|
||||
stack: ["Next.js 16", "React 19", "NestJS 11", "Tailwind 4", "shadcn/ui", "Redis", "WHMCS API"],
|
||||
status: "live",
|
||||
link: "https://techos-asso.fr",
|
||||
img: "/images/projets/techos.png",
|
||||
},
|
||||
{
|
||||
id: "amarea",
|
||||
title: "Amarea Tattoo",
|
||||
subtitle: "Site du studio de tatouage Amarea",
|
||||
desc:
|
||||
"Site vitrine + gestion pour un studio de tatouage. Architecture découplée SPA + API REST avec gestion de médias.",
|
||||
stack: ["Vue 3", "Vite", "NestJS 11", "TypeORM", "MariaDB", "Tailwind 4"],
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "lidge",
|
||||
title: "Lidge",
|
||||
subtitle: "Portail d'abonnements aux services auto-hébergés",
|
||||
desc:
|
||||
"Plateforme perso pour gérer les abonnements à mes services auto-hébergés (Jellyfin, Cloud, Vault, Mail, Git…) avec SSO, tickets et workflow d'accès.",
|
||||
stack: ["Nuxt 3", "NestJS 11", "TypeORM", "MariaDB", "JWT", "Nodemailer"],
|
||||
status: "live",
|
||||
},
|
||||
];
|
||||
|
||||
export const projectsOther: Project[] = [
|
||||
{
|
||||
id: "arboretum",
|
||||
title: "Arboretum",
|
||||
subtitle: "Pilotage de worktrees git + agents IA",
|
||||
desc:
|
||||
"Outil open-source : dashboard web pour piloter des worktrees git et des sessions d'agents IA. Lancé en npx, propulsé par un daemon Node.",
|
||||
stack: ["Node.js", "Vue 3", "TypeScript", "npx"],
|
||||
status: "oss",
|
||||
link: "https://github.com/JohanLeroy",
|
||||
},
|
||||
{
|
||||
id: "ksauce",
|
||||
title: "Bot Discord KSauce",
|
||||
subtitle: "Bot communautaire Discord",
|
||||
desc:
|
||||
"Bot Discord pour la communauté KSauce : commandes, modération et fonctionnalités sur-mesure.",
|
||||
stack: ["Node.js", "TypeScript", "Discord.js"],
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "lplv",
|
||||
title: "Liberty Pour La Vie",
|
||||
subtitle: "Site associatif",
|
||||
desc: "Site web complet pour l'association Liberty Pour La Vie. Projet désormais fermé.",
|
||||
stack: ["Java", "Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"],
|
||||
status: "archived",
|
||||
img: "/images/projets/lplv.png",
|
||||
},
|
||||
{
|
||||
id: "mvg",
|
||||
title: "Mon Voisin Geek",
|
||||
subtitle: "Site vitrine",
|
||||
desc: "Site vitrine simple pour Mon Voisin Geek. Projet désormais fermé.",
|
||||
stack: ["Angular", "Tailwind CSS", "Express.js", "MariaDB"],
|
||||
status: "archived",
|
||||
img: "/images/projets/monvoisingeek.png",
|
||||
},
|
||||
];
|
||||
|
||||
export const experiences: Experience[] = [
|
||||
{
|
||||
company: "Almeria",
|
||||
logo: "/images/entreprises/almeria.png",
|
||||
role: "Développeur",
|
||||
period: "Depuis 2023",
|
||||
duration: "3 ans",
|
||||
desc: "Conception et développement d'applications web et de logiciels d'intégration ERP.",
|
||||
missions: [
|
||||
"Analyse, conception & développement d'applications web",
|
||||
"Création de logiciels d'intégration ERP",
|
||||
"Maintenance & évolution des solutions",
|
||||
],
|
||||
stack: ["Angular", "Express.js", "NestJS", "SQL Server", "MariaDB"],
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
company: "CEREMA",
|
||||
logo: "/images/entreprises/cerema.png",
|
||||
role: "Technicien évaluation — mesure trafic & mobilité",
|
||||
period: "2021 — 2023",
|
||||
duration: "2 ans",
|
||||
desc: "Installation de capteurs et analyse de données de mobilité.",
|
||||
missions: [
|
||||
"Pose de capteurs provisoires (mobilité)",
|
||||
"Optimisation des données et outils numériques",
|
||||
"Analyse du trafic & de la mobilité",
|
||||
],
|
||||
stack: ["Analyse de données", "PHP", "MariaDB"],
|
||||
},
|
||||
{
|
||||
company: "Réseau SNCF",
|
||||
logo: "/images/entreprises/sncf.png",
|
||||
role: "Agent télécom",
|
||||
period: "2019 — 2021",
|
||||
duration: "2 ans",
|
||||
desc:
|
||||
"Maintenance des équipements de sonorisation, d'affichage et de téléphonie sur le réseau ferroviaire.",
|
||||
missions: [
|
||||
"Maintenance préventive & corrective",
|
||||
"Affichage dynamique en gare",
|
||||
"Téléphonie sur les voies",
|
||||
],
|
||||
stack: ["Télécoms", "Maintenance", "Audio", "Affichage"],
|
||||
},
|
||||
{
|
||||
company: "Novoferm",
|
||||
logo: "/images/entreprises/novoferm.png",
|
||||
role: "Stage développeur",
|
||||
period: "2019",
|
||||
duration: "7 semaines",
|
||||
desc:
|
||||
"Application web de récupération de fichiers SharePoint ; réparation et configuration de postes, imprimantes et téléphones.",
|
||||
missions: [],
|
||||
stack: [],
|
||||
},
|
||||
{
|
||||
company: "Pivot Point",
|
||||
logo: "/images/entreprises/pivotpoint.png",
|
||||
role: "Stage développeur",
|
||||
period: "2018",
|
||||
duration: "1 semaine",
|
||||
desc: "Développement d'un site internet en PHP.",
|
||||
missions: [],
|
||||
stack: ["PHP"],
|
||||
},
|
||||
{
|
||||
company: "Actemium",
|
||||
logo: "/images/entreprises/actemium.png",
|
||||
role: "Stage développeur",
|
||||
period: "2018",
|
||||
duration: "1 semaine",
|
||||
desc: "Développement d'applications Windows d'automatisation.",
|
||||
missions: [],
|
||||
stack: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const formation: Formation[] = [
|
||||
{
|
||||
title: "Concepteur Développeur d'Applications",
|
||||
school: "Campus ENI — Nantes",
|
||||
period: "Depuis 2023",
|
||||
desc: "Conception & dév d'applications web/mobiles, architecture logicielle, gestion de projet.",
|
||||
},
|
||||
{
|
||||
title: "BTS Systèmes Numériques",
|
||||
school: "Saint Félix La Salle — Nantes",
|
||||
period: "2021 — 2023",
|
||||
desc: "Systèmes électroniques & informatiques, informatique industrielle & réseaux.",
|
||||
},
|
||||
{
|
||||
title: "BAC PRO Systèmes Numériques",
|
||||
school: "Saint Félix La Salle — Nantes",
|
||||
period: "2018 — 2021",
|
||||
desc: "Systèmes électroniques & réseaux, installation & maintenance.",
|
||||
},
|
||||
{
|
||||
title: "Diplôme National du Brevet",
|
||||
school: "Saint Père en Retz",
|
||||
period: "avant 2018",
|
||||
},
|
||||
];
|
||||
|
||||
export const stackMain: StackGroup[] = [
|
||||
{ label: "Langages", items: ["TypeScript", "JavaScript", "Java", "Python", "PHP"] },
|
||||
{ label: "Frameworks", items: ["NestJS", "Nuxt", "Next.js", "Vue.js", "Angular", "Tailwind CSS", "Astro"] },
|
||||
{ label: "Bases de données", items: ["MariaDB", "MySQL", "PostgreSQL", "Redis"] },
|
||||
{ label: "Outils & DevOps", items: ["Docker", "Git", "AWS", "Terraform", "NGINX", "Plesk"] },
|
||||
{ label: "IDE & Éditeurs", items: ["WebStorm", "PhpStorm", "PyCharm", "Claude Code"] },
|
||||
{ label: "Systèmes", items: ["Debian", "Ubuntu", "macOS"] },
|
||||
];
|
||||
|
||||
export const stackSecondary: StackGroup[] = [
|
||||
{ label: "Langages", items: ["C#", "C++", "Dart", "Kotlin"] },
|
||||
{ label: "Frameworks & libs", items: ["Spring", "Symfony", "Express.js", "FastAPI", "Flutter", "GraphQL", "Vuetify", "Bootstrap"] },
|
||||
{ label: "Bases de données", items: ["MongoDB", "SQLite"] },
|
||||
{ label: "Services & outils", items: ["Firebase", "Swagger", "Postman", "Stripe"] },
|
||||
];
|
||||
|
||||
export const softTech = [
|
||||
"Dév. Frontend",
|
||||
"Dév. Backend",
|
||||
"Analyse & traitement de données",
|
||||
"Mise en production",
|
||||
"Analyse de BDD",
|
||||
"Modélisation UML",
|
||||
"Maquettage",
|
||||
"Sécurité",
|
||||
];
|
||||
|
||||
export const softHuman = [
|
||||
"Gestion de projet",
|
||||
"Travail en équipe",
|
||||
"Communication",
|
||||
"Autonomie",
|
||||
"Organisation",
|
||||
"Adaptation",
|
||||
"Veille techno",
|
||||
];
|
||||
|
||||
export const interests: Interest[] = [
|
||||
{ name: "Jeux vidéo", note: "// détente", icon: "gamepad" },
|
||||
{ name: "Films & Séries", note: "// veille culture", icon: "film" },
|
||||
{ name: "Musique électronique / DJing", note: "// loisir", icon: "music" },
|
||||
{ name: "Projets personnels", note: "// always building", icon: "code" },
|
||||
];
|
||||
|
||||
/* Texte tapé dans le mockup terminal du hero (état final = contenu no-JS). */
|
||||
export const terminalText = `$ whoami
|
||||
johan-leroy
|
||||
$ cat about.json
|
||||
{
|
||||
"name": "Johan Leroy",
|
||||
"role": "Développeur fullstack",
|
||||
"stack": ["TypeScript", "NestJS", "Nuxt", "Next.js"],
|
||||
"location": "Nantes, France",
|
||||
"status": "ouvert aux opportunités"
|
||||
}`;
|
||||
99
src/layouts/Base.astro
Normal file
99
src/layouts/Base.astro
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import { social, profile } from "../data/content";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
const {
|
||||
title = "Johan Leroy — Développeur fullstack",
|
||||
description = "Johan Leroy, développeur fullstack basé à Nantes. TypeScript, NestJS, Nuxt/Vue, Next.js. Conception d'applications web performantes, du front à la mise en production.",
|
||||
} = Astro.props;
|
||||
|
||||
const siteUrl = Astro.site?.href ?? "https://johanleroy.fr/";
|
||||
const canonical = new URL(Astro.url.pathname, siteUrl).href;
|
||||
const ogImage = new URL("/images/og.png", siteUrl).href;
|
||||
const ogDescription =
|
||||
"Développeur fullstack à Nantes — applications web performantes, propres et scalables.";
|
||||
|
||||
// Données structurées (schema.org Person) — aide les moteurs à comprendre la page.
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
name: profile.name,
|
||||
jobTitle: profile.role,
|
||||
url: siteUrl,
|
||||
image: ogImage,
|
||||
email: social.email,
|
||||
address: { "@type": "PostalAddress", addressLocality: "Nantes", addressCountry: "FR" },
|
||||
sameAs: [social.github, social.linkedin, social.gitea],
|
||||
knowsAbout: ["TypeScript", "NestJS", "Nuxt", "Next.js", "Vue.js", "Astro", "Docker", "AWS", "Terraform"],
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="author" content="Johan Leroy" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Johan Leroy" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={ogDescription} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Johan Leroy — Développeur fullstack" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={ogDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/logo/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/logo/favicon-16x16.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/images/logo/apple-touch-icon.png" />
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
|
||||
|
||||
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />
|
||||
|
||||
{/* Thème posé AVANT le premier paint : aucun flash au chargement. */}
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem("jl-theme");
|
||||
var light = saved
|
||||
? saved === "light"
|
||||
: window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
if (light) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.setAttribute("content", "#fafafa");
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="halo" aria-hidden="true"></div>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<div class="page">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import "../scripts/app";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import Ambient from '../components/Ambient.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import SmoothScroll from '../components/islands/SmoothScroll';
|
||||
import EasterEggs from '../components/islands/EasterEggs';
|
||||
import { loadContent } from '../lib/content';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const [profile, site] = await Promise.all([loadContent('profile'), loadContent('site')]);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" sizes="any" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/img/logo/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<SEO title={title} description={description} site={site} />
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="bg-[color:var(--color-bg)]">
|
||||
<Ambient />
|
||||
{site.features.smoothScroll && <SmoothScroll client:load />}
|
||||
<EasterEggs client:idle />
|
||||
|
||||
<Header profile={profile} />
|
||||
|
||||
<main class="pt-16">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer profile={profile} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,59 +0,0 @@
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Astro dev (Vite) expose les .env via import.meta.env ; Node runtime prod via process.env.
|
||||
// On lit les deux pour couvrir tous les cas (dev, preview, prod Plesk Node).
|
||||
const env = {
|
||||
...(typeof process !== 'undefined' ? process.env : {}),
|
||||
...(import.meta.env as Record<string, string | undefined>),
|
||||
};
|
||||
|
||||
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
|
||||
const JWT_SECRET = (env.ADMIN_JWT_SECRET as string | undefined) ?? '';
|
||||
|
||||
// Lazy : on ne throw pas au chargement du module (Astro build importe ce fichier
|
||||
// en SSR sans forcément avoir le .env). On throw au premier usage runtime.
|
||||
let cachedKey: Uint8Array | null = null;
|
||||
function getSecretKey(): Uint8Array {
|
||||
if (cachedKey) return cachedKey;
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
throw new Error(
|
||||
'[auth] ADMIN_JWT_SECRET manquant ou trop court (>= 32 chars). ' +
|
||||
'Générer via `npm run hash:password -- <motdepasse>` et reporter dans .env.',
|
||||
);
|
||||
}
|
||||
cachedKey = new TextEncoder().encode(JWT_SECRET);
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export const ADMIN_COOKIE = 'portfolio_admin';
|
||||
|
||||
export async function verifyPassword(password: string): Promise<boolean> {
|
||||
if (!ADMIN_PASSWORD_HASH) {
|
||||
console.error('[auth] ADMIN_PASSWORD_HASH manquant dans .env');
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compare(password, ADMIN_PASSWORD_HASH);
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
return new SignJWT({ role: 'admin' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(getSecretKey());
|
||||
}
|
||||
|
||||
export async function verifySession(token: string | undefined): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
try {
|
||||
await jwtVerify(token, getSecretKey());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashPasswordCLI(password: string): string {
|
||||
return bcrypt.hashSync(password, 12);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { CONTENT_SCHEMAS, type ContentKey } from '../content/schemas';
|
||||
import type { z } from 'zod';
|
||||
|
||||
const DATA_CONTENT_DIR = join(process.cwd(), 'data', 'content');
|
||||
const SEED_CONTENT_DIR = join(process.cwd(), 'public', 'content');
|
||||
|
||||
type ContentData<K extends ContentKey> = z.infer<(typeof CONTENT_SCHEMAS)[K]>;
|
||||
|
||||
async function ensureContentFile(key: ContentKey): Promise<string> {
|
||||
const target = join(DATA_CONTENT_DIR, `${key}.json`);
|
||||
try {
|
||||
await access(target);
|
||||
return target;
|
||||
} catch {
|
||||
await mkdir(DATA_CONTENT_DIR, { recursive: true });
|
||||
const seed = join(SEED_CONTENT_DIR, `${key}.json`);
|
||||
await copyFile(seed, target);
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadContent<K extends ContentKey>(key: K): Promise<ContentData<K>> {
|
||||
const filePath = await ensureContentFile(key);
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const schema = CONTENT_SCHEMAS[key];
|
||||
return schema.parse(parsed) as ContentData<K>;
|
||||
}
|
||||
|
||||
export async function saveContent<K extends ContentKey>(
|
||||
key: K,
|
||||
data: unknown,
|
||||
): Promise<ContentData<K>> {
|
||||
const schema = CONTENT_SCHEMAS[key];
|
||||
const validated = schema.parse(data) as ContentData<K>;
|
||||
const filePath = await ensureContentFile(key);
|
||||
await writeFile(filePath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
|
||||
return validated;
|
||||
}
|
||||
|
||||
export async function loadAll() {
|
||||
const [profile, skills, experiences, formations, projects, interests, site] = await Promise.all([
|
||||
loadContent('profile'),
|
||||
loadContent('skills'),
|
||||
loadContent('experiences'),
|
||||
loadContent('formations'),
|
||||
loadContent('projects'),
|
||||
loadContent('interests'),
|
||||
loadContent('site'),
|
||||
]);
|
||||
return { profile, skills, experiences, formations, projects, interests, site };
|
||||
}
|
||||
82
src/lib/techIcons.ts
Normal file
82
src/lib/techIcons.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* ============================================================
|
||||
Icônes des technos via le paquet `simple-icons` (chemins SVG
|
||||
officiels). Rendu monochrome (currentColor) par <TechIcon/>,
|
||||
donc adaptatif au thème clair/sombre. Les technos absentes de
|
||||
simple-icons (Java, C#) utilisent un monogramme.
|
||||
============================================================ */
|
||||
import {
|
||||
siTypescript, siJavascript, siPython, siPhp, siNestjs, siNuxtdotjs, siNextdotjs,
|
||||
siVuedotjs, siAngular, siTailwindcss, siAstro, siMariadb, siMysql, siPostgresql,
|
||||
siRedis, siDocker, siGit, siAmazonwebservices, siTerraform, siNginx, siPlesk,
|
||||
siWebstorm, siPhpstorm, siPycharm, siClaude, siDebian, siUbuntu, siApple,
|
||||
siCplusplus, siDart, siKotlin, siSpring, siSymfony, siExpress, siFastapi,
|
||||
siFlutter, siGraphql, siVuetify, siBootstrap, siMongodb, siSqlite, siFirebase,
|
||||
siSwagger, siPostman, siStripe,
|
||||
} from "simple-icons";
|
||||
|
||||
export type TechDef = { path: string } | { mono: string };
|
||||
|
||||
export const techIcons: Record<string, TechDef> = {
|
||||
// Langages
|
||||
"TypeScript": { path: siTypescript.path },
|
||||
"JavaScript": { path: siJavascript.path },
|
||||
"Java": { mono: "Ja" }, // absent de simple-icons
|
||||
"Python": { path: siPython.path },
|
||||
"PHP": { path: siPhp.path },
|
||||
// Frameworks
|
||||
"NestJS": { path: siNestjs.path },
|
||||
"Nuxt": { path: siNuxtdotjs.path },
|
||||
"Next.js": { path: siNextdotjs.path },
|
||||
"Vue.js": { path: siVuedotjs.path },
|
||||
"Angular": { path: siAngular.path },
|
||||
"Tailwind CSS": { path: siTailwindcss.path },
|
||||
"Astro": { path: siAstro.path },
|
||||
// Bases de données
|
||||
"MariaDB": { path: siMariadb.path },
|
||||
"MySQL": { path: siMysql.path },
|
||||
"PostgreSQL": { path: siPostgresql.path },
|
||||
"Redis": { path: siRedis.path },
|
||||
"MongoDB": { path: siMongodb.path },
|
||||
"SQLite": { path: siSqlite.path },
|
||||
// Outils & DevOps
|
||||
"Docker": { path: siDocker.path },
|
||||
"Git": { path: siGit.path },
|
||||
"AWS": { path: siAmazonwebservices.path },
|
||||
"Terraform": { path: siTerraform.path },
|
||||
"NGINX": { path: siNginx.path },
|
||||
"Plesk": { path: siPlesk.path },
|
||||
// IDE & Éditeurs
|
||||
"WebStorm": { path: siWebstorm.path },
|
||||
"PhpStorm": { path: siPhpstorm.path },
|
||||
"PyCharm": { path: siPycharm.path },
|
||||
"Claude Code": { path: siClaude.path },
|
||||
// Systèmes
|
||||
"Debian": { path: siDebian.path },
|
||||
"Ubuntu": { path: siUbuntu.path },
|
||||
"macOS": { path: siApple.path },
|
||||
// Secondaire — langages
|
||||
"C#": { mono: "C#" }, // absent de simple-icons
|
||||
"C++": { path: siCplusplus.path },
|
||||
"Dart": { path: siDart.path },
|
||||
"Kotlin": { path: siKotlin.path },
|
||||
// Secondaire — frameworks & libs
|
||||
"Spring": { path: siSpring.path },
|
||||
"Symfony": { path: siSymfony.path },
|
||||
"Express.js": { path: siExpress.path },
|
||||
"FastAPI": { path: siFastapi.path },
|
||||
"Flutter": { path: siFlutter.path },
|
||||
"GraphQL": { path: siGraphql.path },
|
||||
"Vuetify": { path: siVuetify.path },
|
||||
"Bootstrap": { path: siBootstrap.path },
|
||||
// Secondaire — services & outils
|
||||
"Firebase": { path: siFirebase.path },
|
||||
"Swagger": { path: siSwagger.path },
|
||||
"Postman": { path: siPostman.path },
|
||||
"Stripe": { path: siStripe.path },
|
||||
};
|
||||
|
||||
/** Monogramme de repli pour une techno sans icône (2 premières lettres). */
|
||||
export function monogram(name: string): string {
|
||||
const clean = name.replace(/[^A-Za-z0-9]/g, "");
|
||||
return (clean.charAt(0).toUpperCase() + (clean.charAt(1) || "").toLowerCase()) || "?";
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { ADMIN_COOKIE, verifySession } from './lib/auth';
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const { pathname } = context.url;
|
||||
|
||||
const isAdminPage = pathname.startsWith('/admin') && pathname !== '/admin/login';
|
||||
const isAdminApi =
|
||||
pathname.startsWith('/api/content') ||
|
||||
pathname.startsWith('/api/upload') ||
|
||||
pathname === '/api/auth/logout';
|
||||
|
||||
if (isAdminPage || isAdminApi) {
|
||||
const token = context.cookies.get(ADMIN_COOKIE)?.value;
|
||||
const ok = await verifySession(token);
|
||||
if (!ok) {
|
||||
if (isAdminApi) {
|
||||
return new Response(JSON.stringify({ error: 'unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return context.redirect('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Signal Lost" description="Page introuvable">
|
||||
<section class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 relative">
|
||||
<!-- Static noise overlay -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none opacity-20 mix-blend-screen"
|
||||
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22300%22 height=%22300%22><filter id=%22n%22><feTurbulence type=%22fractalNoise%22 baseFrequency=%220.95%22 numOctaves=%222%22 stitchTiles=%22stitch%22/></filter><rect width=%22300%22 height=%22300%22 filter=%22url(%23n)%22/></svg>'); background-size: 300px;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 text-center max-w-xl mx-auto">
|
||||
<div class="flex items-center gap-2 justify-center mb-6">
|
||||
<span class="led led-red animate-led-red"></span>
|
||||
<p class="font-mono text-sm text-[color:var(--color-red)] uppercase tracking-[0.3em]">
|
||||
signal lost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="stencil text-[10rem] md:text-[14rem] font-bold leading-none mb-6 flex justify-center gap-2"
|
||||
style="filter: drop-shadow(0 0 20px rgba(0, 240, 255, 0.4));"
|
||||
>
|
||||
<span class="text-[color:var(--color-cyan)] glow-text-cyan animate-flicker">4</span>
|
||||
<span class="text-[color:var(--color-magenta)] glow-text-magenta">0</span>
|
||||
<span class="text-[color:var(--color-cyan)] glow-text-cyan animate-flicker">4</span>
|
||||
</h1>
|
||||
|
||||
<div class="font-mono text-xs text-[color:var(--color-text-muted)] uppercase tracking-[0.2em] mb-10 space-y-2">
|
||||
<p>
|
||||
<span class="text-[color:var(--color-magenta)]">></span> frequency not found in crate
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-[color:var(--color-magenta)]">></span> rejoin the main feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="/" class="btn-primary">
|
||||
<< retour au sound system
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import '../../../styles/global.css';
|
||||
import { loadContent } from '../../../lib/content';
|
||||
import { CONTENT_SCHEMAS, type ContentKey } from '../../../content/schemas';
|
||||
import { EditPanel } from '../../../components/admin/EditPanel';
|
||||
|
||||
const { section } = Astro.params;
|
||||
|
||||
if (!section || !(section in CONTENT_SCHEMAS)) {
|
||||
return Astro.redirect('/admin');
|
||||
}
|
||||
|
||||
const key = section as ContentKey;
|
||||
const data = await loadContent(key);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin — Édition {section}</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;500&family=Bebas+Neue&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="aurora-bg min-h-screen">
|
||||
<header class="border-b border-[color:var(--color-border)] backdrop-blur-xl bg-[rgba(10,10,15,0.7)] sticky top-0 z-40">
|
||||
<div class="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/admin" class="font-mono text-sm text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)]">
|
||||
← dashboard
|
||||
</a>
|
||||
<span class="text-[color:var(--color-text-dim)]">/</span>
|
||||
<span class="font-display uppercase tracking-wider text-[color:var(--color-cyan)]">
|
||||
{section}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/content/${section}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="font-mono text-xs text-[color:var(--color-text-dim)] hover:text-[color:var(--color-cyan)]"
|
||||
>
|
||||
↗ JSON courant
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-4 py-6 pb-24">
|
||||
<EditPanel section={key} data={data} client:load />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import '../../styles/global.css';
|
||||
import { CONTENT_SCHEMAS } from '../../content/schemas';
|
||||
|
||||
const sections = Object.keys(CONTENT_SCHEMAS) as Array<keyof typeof CONTENT_SCHEMAS>;
|
||||
|
||||
const sectionLabels: Record<string, { label: string; icon: string; description: string }> = {
|
||||
profile: { label: 'Profil', icon: '👤', description: 'Nom, titre, bio, CTA hero, réseaux sociaux' },
|
||||
skills: { label: 'Compétences', icon: '⚡', description: 'Stack technique primaire & secondaire' },
|
||||
experiences: { label: 'Expériences', icon: '💼', description: 'Parcours professionnel, compétences pro' },
|
||||
formations: { label: 'Formations', icon: '🎓', description: 'Diplômes et cursus' },
|
||||
projects: { label: 'Projets', icon: '🚀', description: 'Portfolio de réalisations' },
|
||||
interests: { label: "Centres d'intérêt", icon: '🎧', description: 'Hobbies affichés sur la home' },
|
||||
site: { label: 'Site', icon: '⚙️', description: 'Meta SEO, features globales' },
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin — Dashboard</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="aurora-bg min-h-screen">
|
||||
<header class="border-b border-[color:var(--color-border)] backdrop-blur-xl bg-[rgba(10,10,15,0.7)] sticky top-0 z-40">
|
||||
<div class="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-display text-lg tracking-wider uppercase">
|
||||
<span class="text-[color:var(--color-magenta)] glow-text-magenta">Admin</span>
|
||||
<span class="text-[color:var(--color-text-muted)] mx-2">/</span>
|
||||
<span class="text-[color:var(--color-text)]">Portfolio</span>
|
||||
</span>
|
||||
<span class="w-2 h-2 rounded-full bg-[color:var(--color-magenta)] animate-pulse-neon"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" target="_blank" class="text-sm font-mono text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
↗ voir le site
|
||||
</a>
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="text-sm font-mono text-[color:var(--color-text-muted)] hover:text-[color:var(--color-magenta)] transition"
|
||||
>
|
||||
déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-4 py-12">
|
||||
<div class="mb-10">
|
||||
<p class="font-mono text-xs text-[color:var(--color-cyan)] uppercase tracking-widest mb-2">
|
||||
// dashboard
|
||||
</p>
|
||||
<h1 class="font-display text-4xl font-bold mb-2">Édition du contenu</h1>
|
||||
<p class="text-[color:var(--color-text-muted)] max-w-2xl">
|
||||
Choisis une section à éditer. Les changements sont enregistrés directement dans les fichiers JSON — la mise à jour est immédiate côté site, aucune recompilation nécessaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{
|
||||
sections.map((s) => {
|
||||
const meta = sectionLabels[s] ?? { label: s, icon: '📄', description: '' };
|
||||
return (
|
||||
<a
|
||||
href={`/admin/edit/${s}`}
|
||||
class="card-neon hover:border-[color:var(--color-cyan)] transition group"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-2xl">{meta.icon}</span>
|
||||
<span class="text-xs font-mono text-[color:var(--color-text-dim)]">{s}.json</span>
|
||||
</div>
|
||||
<h3 class="font-display text-xl mb-2 group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||||
{meta.label}
|
||||
</h3>
|
||||
<p class="text-sm text-[color:var(--color-text-muted)]">{meta.description}</p>
|
||||
<span class="mt-4 block text-xs font-mono text-[color:var(--color-cyan)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
→ éditer
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/admin/login';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import '../../styles/global.css';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin — Connexion</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="aurora-bg min-h-screen flex items-center justify-center px-4">
|
||||
<form
|
||||
id="login-form"
|
||||
class="card-neon w-full max-w-sm"
|
||||
style="border-color: rgba(0, 240, 255, 0.3)"
|
||||
>
|
||||
<p class="font-mono text-xs text-[color:var(--color-cyan)] mb-3 uppercase tracking-widest">
|
||||
// Admin access
|
||||
</p>
|
||||
<h1 class="font-display text-2xl font-bold mb-6">
|
||||
<span class="text-[color:var(--color-text)]">Authentification</span>
|
||||
</h1>
|
||||
|
||||
<label class="block mb-4">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-[color:var(--color-text-muted)]"
|
||||
>Mot de passe</span
|
||||
>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full bg-[color:var(--color-bg)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none focus:ring-1 focus:ring-[color:var(--color-cyan)] px-4 py-2 rounded text-[color:var(--color-text)] font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p id="error-msg" class="text-sm text-[color:var(--color-magenta)] mb-4 hidden font-mono">
|
||||
Mot de passe invalide
|
||||
</p>
|
||||
|
||||
<button type="submit" class="btn-primary w-full justify-center"> Entrer </button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form') as HTMLFormElement;
|
||||
const err = document.getElementById('error-msg') as HTMLElement;
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.classList.add('hidden');
|
||||
const data = new FormData(form);
|
||||
const password = data.get('password') as string;
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/admin';
|
||||
} else {
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { ADMIN_COOKIE, createSession, verifyPassword } from '../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
let body: { password?: string } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'invalid_body' }), { status: 400 });
|
||||
}
|
||||
|
||||
const password = body.password ?? '';
|
||||
const valid = await verifyPassword(password);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await createSession();
|
||||
cookies.set(ADMIN_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { ADMIN_COOKIE } from '../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ cookies }) => {
|
||||
cookies.delete(ADMIN_COOKIE, { path: '/' });
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { loadContent, saveContent } from '../../../lib/content';
|
||||
import { CONTENT_SCHEMAS, type ContentKey } from '../../../content/schemas';
|
||||
|
||||
function isValidSection(s: string): s is ContentKey {
|
||||
return s in CONTENT_SCHEMAS;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const section = params.section ?? '';
|
||||
if (!isValidSection(section)) {
|
||||
return new Response(JSON.stringify({ error: 'unknown_section' }), { status: 404 });
|
||||
}
|
||||
try {
|
||||
const data = await loadContent(section);
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[content] load_failed', section, err);
|
||||
return new Response(JSON.stringify({ error: 'load_failed' }), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const section = params.section ?? '';
|
||||
if (!isValidSection(section)) {
|
||||
return new Response(JSON.stringify({ error: 'unknown_section' }), { status: 404 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'invalid_body' }), { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await saveContent(section, body);
|
||||
return new Response(JSON.stringify({ ok: true, data: saved }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[content] validation_failed', section, err);
|
||||
const message = err instanceof Error && err.name === 'ZodError' ? err.message : undefined;
|
||||
return new Response(
|
||||
JSON.stringify(message ? { error: 'validation_failed', message } : { error: 'validation_failed' }),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve, extname } from 'node:path';
|
||||
|
||||
const UPLOADS_ROOT = resolve(process.cwd(), 'data', 'uploads');
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.gif': 'image/gif',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const rawPath = (params.path as string | undefined) ?? '';
|
||||
if (!rawPath) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
|
||||
const target = resolve(join(UPLOADS_ROOT, rawPath));
|
||||
if (!target.startsWith(UPLOADS_ROOT + '/') && target !== UPLOADS_ROOT) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(target);
|
||||
if (!stats.isFile()) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
} catch {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
|
||||
const ext = extname(target).toLowerCase();
|
||||
const type = MIME[ext] ?? 'application/octet-stream';
|
||||
const buffer = await readFile(target);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Content-Length': String(buffer.length),
|
||||
'Cache-Control': 'public, max-age=300, must-revalidate',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join, extname, basename } from 'node:path';
|
||||
|
||||
const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads');
|
||||
|
||||
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
||||
const DOCUMENT_EXT = new Set(['.pdf']);
|
||||
|
||||
const MAX_SIZE: Record<'image' | 'document', number> = {
|
||||
image: 5 * 1024 * 1024,
|
||||
document: 8 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function sanitize(filename: string): string {
|
||||
const name = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return name.slice(-120);
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const file = form.get('file');
|
||||
const folder = (form.get('folder') as string | null) ?? '';
|
||||
const kindRaw = (form.get('kind') as string | null) ?? 'image';
|
||||
const kind: 'image' | 'document' = kindRaw === 'document' ? 'document' : 'image';
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return new Response(JSON.stringify({ error: 'no_file' }), { status: 400 });
|
||||
}
|
||||
|
||||
const ext = extname(file.name).toLowerCase();
|
||||
const allowed = kind === 'document' ? DOCUMENT_EXT : IMAGE_EXT;
|
||||
if (!allowed.has(ext)) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_extension' }), { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE[kind]) {
|
||||
return new Response(JSON.stringify({ error: 'file_too_large' }), { status: 400 });
|
||||
}
|
||||
|
||||
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const bucket = kind === 'document' ? 'assets' : 'img';
|
||||
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
|
||||
const filename = sanitize(file.name);
|
||||
|
||||
try {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(join(targetDir, filename), buffer);
|
||||
} catch (err) {
|
||||
console.error('[upload] write_failed', err);
|
||||
return new Response(JSON.stringify({ error: 'write_failed' }), { status: 500 });
|
||||
}
|
||||
|
||||
const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`;
|
||||
return new Response(JSON.stringify({ ok: true, url }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Reveal from '../components/islands/Reveal';
|
||||
import Setlist from '../components/islands/Setlist';
|
||||
import PatchBay from '../components/islands/PatchBay';
|
||||
import { loadContent } from '../lib/content';
|
||||
|
||||
const experiences = await loadContent('experiences');
|
||||
|
||||
const firstYear = 2018;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearsTotal = currentYear - firstYear;
|
||||
---
|
||||
|
||||
<BaseLayout title="Expérience" description="Mon parcours pro en setlist DJ">
|
||||
<section class="py-24 min-h-screen">
|
||||
<div class="container mx-auto px-4">
|
||||
<Reveal client:load>
|
||||
<div class="mb-12">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<p
|
||||
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-magenta)] mb-2 flex items-center gap-2"
|
||||
>
|
||||
<span class="led led-red animate-led-red"></span>
|
||||
// setlist.current
|
||||
</p>
|
||||
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||
Mon <span class="text-[color:var(--color-magenta)] glow-text-magenta">expérience</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] space-y-1">
|
||||
<div>[tracks: {experiences.items.length}]</div>
|
||||
<div>[duration: {yearsTotal}y]</div>
|
||||
<div>[status: <span class="text-[color:var(--color-acid)]">on air</span>]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal className="max-w-5xl mx-auto" client:visible>
|
||||
<Setlist items={experiences.items} client:load />
|
||||
</Reveal>
|
||||
|
||||
{
|
||||
(experiences.skills.technical.length > 0 || experiences.skills.soft.length > 0) && (
|
||||
<div class="max-w-5xl mx-auto mt-24">
|
||||
<Reveal client:visible>
|
||||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-2">
|
||||
// patch bay
|
||||
</p>
|
||||
<h2 class="stencil text-2xl sm:text-3xl md:text-4xl uppercase leading-none">
|
||||
Compétences <span class="text-[color:var(--color-cyan)] glow-text-cyan">pros</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal client:visible>
|
||||
<PatchBay
|
||||
technical={experiences.skills.technical}
|
||||
soft={experiences.skills.soft}
|
||||
client:load
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,164 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Reveal from '../components/islands/Reveal';
|
||||
import { loadContent } from '../lib/content';
|
||||
|
||||
const formations = await loadContent('formations');
|
||||
|
||||
function extractLocation(school: string): string {
|
||||
const match = school.match(/(Nantes|Saint\s?Père[^\s]*|Paris|Lyon|Rennes|Lille)/i);
|
||||
return match ? match[1].toUpperCase() : 'FR';
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Formations" description="Mon parcours d'étude">
|
||||
<section class="py-24 min-h-screen">
|
||||
<div class="container mx-auto px-4">
|
||||
<Reveal client:load>
|
||||
<div class="mb-16">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-2 flex items-center gap-2"
|
||||
>
|
||||
<span class="led led-cyan"></span>
|
||||
// bpm log · crate digging
|
||||
</p>
|
||||
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">formations</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] text-right space-y-1">
|
||||
<div>[entries: {formations.items.length}]</div>
|
||||
<div>[status: stored]</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[color:var(--color-text-muted)] mt-4 max-w-xl font-mono text-sm">
|
||||
<span class="text-[color:var(--color-magenta)]">></span> un parcours d'étude dans le développement et les
|
||||
systèmes numériques.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-12">
|
||||
{
|
||||
formations.items.map((f, i) => {
|
||||
const isEven = i % 2 === 0;
|
||||
const tilt = isEven ? -1.5 : 1.8;
|
||||
const location = extractLocation(f.school);
|
||||
return (
|
||||
<Reveal
|
||||
delay={i * 0.1}
|
||||
direction={isEven ? 'left' : 'right'}
|
||||
client:visible
|
||||
>
|
||||
<div
|
||||
class="formation-ticket relative group transition-transform duration-500"
|
||||
style={`--tilt: ${tilt}deg; max-width: 640px; margin-${isEven ? 'right' : 'left'}: auto;`}
|
||||
>
|
||||
<article
|
||||
class="relative bg-[#F5EDE0] text-[#14130E] rounded-sm overflow-hidden shadow-[0_20px_50px_-20px_rgba(0,0,0,0.8)]"
|
||||
style="background-image: repeating-linear-gradient(0deg, rgba(20,19,14,0.025) 0 1px, transparent 1px 4px);"
|
||||
>
|
||||
<!-- Header ticket -->
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b-2 border-dashed border-[#14130E]/30">
|
||||
<span class="stencil text-[11px] uppercase tracking-[0.3em]">
|
||||
Free Party Invitation
|
||||
</span>
|
||||
<span class="stencil text-[11px] uppercase tracking-[0.2em] text-[#14130E]/70">
|
||||
EDU SYSTEM · #{String(i + 1).padStart(3, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-5 md:p-8 grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-5 sm:gap-6">
|
||||
<div class="font-mono text-[10px] uppercase tracking-widest space-y-2 sm:border-r-2 sm:border-dashed sm:border-[#14130E]/30 sm:pr-6">
|
||||
<div>
|
||||
<div class="text-[#14130E]/50">DATE</div>
|
||||
<div class="stencil text-base tracking-wider">{f.period}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[#14130E]/50">LOCATION</div>
|
||||
<div class="stencil text-base tracking-wider">{location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[#14130E]/50">ACCESS</div>
|
||||
<div class="stencil text-base tracking-wider">ALL AREAS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="stencil text-2xl md:text-3xl uppercase leading-tight mb-2">
|
||||
{f.title}
|
||||
</h3>
|
||||
{f.schoolUrl ? (
|
||||
<a
|
||||
href={f.schoolUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono text-xs text-[#7A1027] hover:underline uppercase tracking-wider"
|
||||
>
|
||||
{f.school} ↗
|
||||
</a>
|
||||
) : (
|
||||
<p class="font-mono text-xs uppercase tracking-wider">{f.school}</p>
|
||||
)}
|
||||
{f.description && (
|
||||
<p class="text-sm text-[#14130E]/75 mt-4 font-sans">{f.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-3 border-t-2 border-dashed border-[#14130E]/30 bg-[#EDE3D0]">
|
||||
<span class="stencil text-[11px] uppercase tracking-[0.3em] text-[#7A1027]">
|
||||
★ Admit one
|
||||
</span>
|
||||
<span class="font-mono text-[10px] uppercase tracking-widest text-[#14130E]/60">
|
||||
no refund / no exchange
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bar code imitation -->
|
||||
<div class="absolute right-3 top-12 flex gap-[2px]">
|
||||
{Array.from({ length: 24 }).map((_, j) => (
|
||||
<span
|
||||
class="block w-[1.5px] bg-[#14130E]/50"
|
||||
style={`height: ${12 + ((j * 7) % 18)}px`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Punch holes for tear -->
|
||||
<div class="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-[color:var(--color-bg)]"></div>
|
||||
<div class="absolute right-0 top-1/2 translate-x-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-[color:var(--color-bg)]"></div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.formation-ticket {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.formation-ticket {
|
||||
transform: rotate(var(--tilt, 0deg));
|
||||
}
|
||||
.formation-ticket:hover {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.formation-ticket,
|
||||
.formation-ticket:hover {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,377 +1,25 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import RolesMorph from '../components/islands/RolesMorph';
|
||||
import Reveal from '../components/islands/Reveal';
|
||||
import RackModule from '../components/islands/RackModule';
|
||||
import SplitReveal from '../components/islands/SplitReveal';
|
||||
import Waveform from '../components/islands/Waveform';
|
||||
import BootSequence from '../components/islands/BootSequence';
|
||||
import { Icon } from '@iconify-icon/react';
|
||||
import { loadAll } from '../lib/content';
|
||||
|
||||
const { profile, skills, interests } = await loadAll();
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import Hero from "../components/Hero.astro";
|
||||
import About from "../components/About.astro";
|
||||
import Projects from "../components/Projects.astro";
|
||||
import Timeline from "../components/Timeline.astro";
|
||||
import Stack from "../components/Stack.astro";
|
||||
import Interests from "../components/Interests.astro";
|
||||
import Contact from "../components/Contact.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<BootSequence client:load />
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="relative min-h-[calc(100vh-4rem)] flex items-center overflow-hidden">
|
||||
<!-- Stickers décoratifs (très discrets) -->
|
||||
<div
|
||||
class="absolute top-24 right-6 md:right-12 z-20 flex items-center gap-2 sticker text-[color:var(--color-red)]"
|
||||
>
|
||||
<span class="led led-red animate-led-red"></span>
|
||||
LIVE · 174 BPM
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-20 left-6 md:left-12 z-20 sticker text-[color:var(--color-cyan)]"
|
||||
style="transform: rotate(2deg)"
|
||||
>
|
||||
Nantes FR · 2019 → now
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 relative z-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-10 lg:gap-16 items-center">
|
||||
<div class="max-w-3xl order-2 lg:order-1">
|
||||
<p
|
||||
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-4 flex items-center gap-2"
|
||||
>
|
||||
<span class="led led-cyan"></span>
|
||||
> frequency: 174 bpm · hardtek
|
||||
</p>
|
||||
|
||||
<h1 class="stencil text-5xl sm:text-6xl md:text-8xl lg:text-9xl leading-[0.9] mb-6 uppercase">
|
||||
<SplitReveal
|
||||
text="Johan"
|
||||
as="span"
|
||||
className="block text-[color:var(--color-text)]"
|
||||
delay={0.1}
|
||||
client:load
|
||||
/>
|
||||
<SplitReveal
|
||||
text="Leroy"
|
||||
as="span"
|
||||
className="block text-[color:var(--color-acid)] glow-text-acid"
|
||||
delay={0.45}
|
||||
client:load
|
||||
/>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="font-display text-xl sm:text-2xl md:text-4xl mb-8 min-h-[2.5rem] flex items-center gap-2"
|
||||
>
|
||||
<span class="text-[color:var(--color-text-dim)]">[</span>
|
||||
<RolesMorph roles={profile.hero.roles} client:load />
|
||||
<span class="text-[color:var(--color-text-dim)]">]</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="font-mono text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] mb-6 italic"
|
||||
>
|
||||
// code propre, sono sale
|
||||
</p>
|
||||
|
||||
<p class="text-base sm:text-lg text-[color:var(--color-text-muted)] mb-10 max-w-2xl">
|
||||
{profile.bio}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{
|
||||
profile.hero.cta.map((btn) => {
|
||||
const href = btn.label.toLowerCase().includes('cv')
|
||||
? (profile.cv ?? btn.href)
|
||||
: btn.href;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={btn.external ? '_blank' : undefined}
|
||||
rel={btn.external ? 'noopener noreferrer' : undefined}
|
||||
class:list={[btn.variant === 'primary' ? 'btn-primary' : 'btn-ghost']}
|
||||
>
|
||||
{btn.label}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.hero.photo && (
|
||||
<div class="order-1 lg:order-2 flex justify-center lg:justify-end">
|
||||
<div class="hero-photo relative w-56 sm:w-64 lg:w-[360px] aspect-square">
|
||||
<span class="hero-photo__bracket hero-photo__bracket--tl"></span>
|
||||
<span class="hero-photo__bracket hero-photo__bracket--tr"></span>
|
||||
<span class="hero-photo__bracket hero-photo__bracket--bl"></span>
|
||||
<span class="hero-photo__bracket hero-photo__bracket--br"></span>
|
||||
<div class="hero-photo__frame">
|
||||
<img
|
||||
src={profile.hero.photo}
|
||||
alt={profile.name}
|
||||
width="360"
|
||||
height="360"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span class="hero-photo__scanlines" aria-hidden="true"></span>
|
||||
<span class="hero-photo__led" aria-hidden="true">
|
||||
<span class="led led-acid animate-pulse"></span>
|
||||
REC · 174 BPM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waveform en bas du hero -->
|
||||
<div class="absolute bottom-0 inset-x-0 h-20 flex items-end pointer-events-none opacity-60">
|
||||
<Waveform bars={80} color="var(--color-cyan)" className="w-full h-full" client:visible />
|
||||
</div>
|
||||
|
||||
<!-- Scroll indicator -->
|
||||
<div
|
||||
class="absolute bottom-24 left-1/2 -translate-x-1/2 z-10 flex flex-col items-center gap-2 text-[color:var(--color-text-dim)] font-mono text-[10px]"
|
||||
>
|
||||
<span class="uppercase tracking-widest">scroll</span>
|
||||
<div class="w-px h-10 bg-gradient-to-b from-[color:var(--color-cyan)] to-transparent"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SOUND SYSTEM RACK — Primary -->
|
||||
<section id="skills" class="py-24 relative">
|
||||
<div class="container mx-auto px-4">
|
||||
<Reveal client:visible>
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-magenta)] mb-2">
|
||||
// ch.01 — main rack
|
||||
</p>
|
||||
<h2 class="stencil text-4xl md:text-5xl uppercase leading-none">
|
||||
Technologies <span class="text-[color:var(--color-cyan)] glow-text-cyan">maîtrisées</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
<span class="led led-cyan animate-pulse"></span>
|
||||
system online · 174 bpm
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr">
|
||||
{
|
||||
skills.primary.map((cat, i) => (
|
||||
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
||||
<RackModule
|
||||
label={cat.label}
|
||||
items={cat.items}
|
||||
accent="cyan"
|
||||
bpm={174}
|
||||
index={i}
|
||||
client:visible
|
||||
/>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<Reveal client:visible>
|
||||
<div class="mt-16 mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-acid)] mb-2">
|
||||
// ch.02 — side rack
|
||||
</p>
|
||||
<h2 class="stencil text-3xl md:text-4xl uppercase leading-none">
|
||||
Autres technologies <span class="text-[color:var(--color-magenta)] glow-text-magenta">manipulées</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
<span class="led led-acid animate-pulse"></span>
|
||||
patch bank · 140 bpm
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-fr">
|
||||
{
|
||||
skills.secondary.map((cat, i) => (
|
||||
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
||||
<RackModule
|
||||
label={cat.label}
|
||||
items={cat.items}
|
||||
accent="magenta"
|
||||
bpm={140}
|
||||
index={skills.primary.length + i}
|
||||
client:visible
|
||||
/>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OFF DECKS (interests) -->
|
||||
<section class="py-24 relative">
|
||||
<div class="container mx-auto px-4">
|
||||
<Reveal client:visible>
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-acid)] mb-2">
|
||||
// ch.03 — off decks
|
||||
</p>
|
||||
<h2 class="stencil text-4xl md:text-5xl uppercase leading-none">
|
||||
Centres d'<span class="text-[color:var(--color-magenta)] glow-text-magenta">intérêt</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6 auto-rows-fr">
|
||||
{
|
||||
interests.items.map((interest, i) => {
|
||||
const color =
|
||||
interest.color === 'cyan'
|
||||
? 'var(--color-cyan)'
|
||||
: interest.color === 'magenta'
|
||||
? 'var(--color-magenta)'
|
||||
: interest.color === 'violet'
|
||||
? 'var(--color-violet)'
|
||||
: 'var(--color-ice)';
|
||||
return (
|
||||
<Reveal delay={i * 0.1} className="h-full" client:visible>
|
||||
<div
|
||||
class="card-neon h-full flex flex-col items-center justify-center text-center hover:-translate-y-2 transition-transform"
|
||||
style={`border-color: ${color}40`}
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 mb-3 flex items-center justify-center rounded-full"
|
||||
style={`background: ${color}15; color: ${color}; box-shadow: 0 0 20px ${color}40`}
|
||||
>
|
||||
<Icon icon={interest.icon} width="24" height="24" />
|
||||
</div>
|
||||
<p class="font-display text-sm uppercase tracking-wider">{interest.label}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero-photo {
|
||||
--bracket: 18px;
|
||||
--bracket-color: var(--color-cyan);
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero-photo {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.hero-photo:hover {
|
||||
transform: rotate(0deg) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-photo__frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid color-mix(in oklab, var(--color-cyan) 70%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in oklab, var(--color-cyan) 25%, transparent),
|
||||
0 0 28px color-mix(in oklab, var(--color-cyan) 35%, transparent),
|
||||
inset 0 0 60px color-mix(in oklab, var(--color-magenta) 18%, transparent);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.hero-photo__scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 240, 255, 0.06) 0px,
|
||||
rgba(0, 240, 255, 0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.hero-photo__led {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(10, 10, 15, 0.7);
|
||||
border: 1px solid color-mix(in oklab, var(--color-acid) 60%, transparent);
|
||||
color: var(--color-acid);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-photo__bracket {
|
||||
position: absolute;
|
||||
width: var(--bracket);
|
||||
height: var(--bracket);
|
||||
border-color: var(--bracket-color);
|
||||
border-style: solid;
|
||||
z-index: 2;
|
||||
}
|
||||
.hero-photo__bracket--tl {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
border-width: 2px 0 0 2px;
|
||||
}
|
||||
.hero-photo__bracket--tr {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
border-width: 2px 2px 0 0;
|
||||
}
|
||||
.hero-photo__bracket--bl {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
border-width: 0 0 2px 2px;
|
||||
}
|
||||
.hero-photo__bracket--br {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-photo,
|
||||
.hero-photo:hover {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<Base>
|
||||
<Header />
|
||||
<main id="top">
|
||||
<Hero />
|
||||
<About />
|
||||
<Projects />
|
||||
<Timeline />
|
||||
<Stack />
|
||||
<Interests />
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Reveal from '../components/islands/Reveal';
|
||||
import Crate from '../components/islands/Crate';
|
||||
import { loadContent } from '../lib/content';
|
||||
|
||||
const projects = await loadContent('projects');
|
||||
const featured = projects.items.filter((p) => p.featured);
|
||||
const others = projects.items.filter((p) => !p.featured);
|
||||
---
|
||||
|
||||
<BaseLayout title="Projets" description="Sélection de mes projets — le crate">
|
||||
<section class="pt-16 pb-8 relative">
|
||||
<div class="container mx-auto px-4">
|
||||
<Reveal client:load>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<p
|
||||
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-magenta)] mb-2 flex items-center gap-2"
|
||||
>
|
||||
<span class="led led-cyan"></span>
|
||||
// crate v1.0 · digging
|
||||
</p>
|
||||
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">projets</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
{featured.length + others.length} skeuds · {featured.length} featured
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<p class="text-[color:var(--color-text-muted)] max-w-xl font-mono text-sm mt-4">
|
||||
<span class="text-[color:var(--color-cyan)]">></span> une sélection de réalisations —
|
||||
live, archivées, privées. Scroll pour feuilleter le bac.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{
|
||||
featured.length > 0 && (
|
||||
<section class="relative">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<Reveal client:visible>
|
||||
<h2 class="stencil text-xl uppercase tracking-wider text-[color:var(--color-acid)] glow-text-acid">
|
||||
★ Featured
|
||||
</h2>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Crate projects={featured} client:load />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
others.length > 0 && (
|
||||
<section class="relative">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<Reveal client:visible>
|
||||
<h2 class="stencil text-xl uppercase tracking-wider text-[color:var(--color-text-muted)]">
|
||||
Autres skeuds
|
||||
</h2>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Crate projects={others} client:load />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</BaseLayout>
|
||||
119
src/scripts/app.ts
Normal file
119
src/scripts/app.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/* ============================================================
|
||||
Comportements client (bundlés par Astro).
|
||||
Le thème initial est posé AVANT le paint par le script inline
|
||||
du <head> (cf. Base.astro) pour éviter tout flash.
|
||||
Tout respecte prefers-reduced-motion.
|
||||
============================================================ */
|
||||
|
||||
const reduced =
|
||||
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
/* ---------- Bascule de thème (persistée) ---------- */
|
||||
function applyTheme(theme: "light" | "dark"): void {
|
||||
if (theme === "light") document.documentElement.setAttribute("data-theme", "light");
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafafa" : "#09090b");
|
||||
}
|
||||
function setupThemeToggle(): void {
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
const isLight = document.documentElement.getAttribute("data-theme") === "light";
|
||||
const next = isLight ? "dark" : "light";
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem("jl-theme", next); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Effet machine à écrire (mockup terminal) ---------- */
|
||||
function setupTyping(): void {
|
||||
const el = document.getElementById("typed");
|
||||
if (!el) return;
|
||||
const full = el.textContent ?? ""; // le contenu HTML sert d'état final (no-JS friendly)
|
||||
if (reduced) return; // mouvement réduit : on garde l'état final
|
||||
el.textContent = "";
|
||||
let i = 0;
|
||||
const tick = () => {
|
||||
i += 1;
|
||||
el.textContent = full.slice(0, i);
|
||||
if (i < full.length) {
|
||||
const ch = full.charAt(i - 1);
|
||||
let d = 16;
|
||||
if (ch === "\n") d = 80;
|
||||
else if (ch === "{" || ch === "}") d = 40;
|
||||
window.setTimeout(tick, d);
|
||||
}
|
||||
};
|
||||
window.setTimeout(tick, 480);
|
||||
}
|
||||
|
||||
/* ---------- Apparition des sections au scroll ---------- */
|
||||
function setupReveal(): void {
|
||||
const els = document.querySelectorAll<HTMLElement>(".reveal");
|
||||
if (reduced || !("IntersectionObserver" in window)) {
|
||||
els.forEach((el) => el.classList.add("is-visible"));
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("is-visible");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -7% 0px" }
|
||||
);
|
||||
els.forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
/* ---------- Surlignage de la nav selon la section visible ---------- */
|
||||
function setupActiveNav(): void {
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
const sections = document.querySelectorAll<HTMLElement>("section[id]");
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (!e.isIntersecting) return;
|
||||
const id = e.target.id;
|
||||
document.querySelectorAll<HTMLElement>("[data-nav]").forEach((link) => {
|
||||
link.classList.toggle("is-active", link.getAttribute("data-nav") === id);
|
||||
});
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-45% 0px -50% 0px" }
|
||||
);
|
||||
sections.forEach((s) => io.observe(s));
|
||||
}
|
||||
|
||||
/* ---------- Menu mobile ---------- */
|
||||
function setupMobileMenu(): void {
|
||||
const burger = document.getElementById("burger");
|
||||
const menu = document.getElementById("mobile-menu");
|
||||
if (!burger || !menu) return;
|
||||
const close = () => {
|
||||
menu.classList.remove("is-open");
|
||||
burger.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
burger.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-open");
|
||||
burger.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
menu.querySelectorAll("a").forEach((a) => a.addEventListener("click", close));
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth >= 820) close();
|
||||
});
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
setupThemeToggle();
|
||||
setupTyping();
|
||||
setupReveal();
|
||||
setupActiveNav();
|
||||
setupMobileMenu();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
@@ -1,373 +1,268 @@
|
||||
@import "tailwindcss";
|
||||
/* ============================================================
|
||||
Portfolio — Johan Leroy, développeur fullstack
|
||||
Thème sombre par défaut (:root), thème clair via [data-theme="light"].
|
||||
Aucune dépendance réseau (polices auto-hébergées).
|
||||
============================================================ */
|
||||
|
||||
/* =========================================================================
|
||||
PORTFOLIO — Cyberpunk Dark / Free Party v2
|
||||
Palette acid : noir warehouse + cyan UV + acid yellow + magenta + LED rouge
|
||||
========================================================================= */
|
||||
/* ---------- Polices auto-hébergées (sous-ensemble latin) ---------- */
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:400;font-display:swap;src:url('/fonts/inter-400.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:500;font-display:swap;src:url('/fonts/inter-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:600;font-display:swap;src:url('/fonts/inter-600.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:700;font-display:swap;src:url('/fonts/inter-700.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:800;font-display:swap;src:url('/fonts/inter-800.woff2') format('woff2');}
|
||||
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/fonts/jetbrains-mono-400.woff2') format('woff2');}
|
||||
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:500;font-display:swap;src:url('/fonts/jetbrains-mono-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/fonts/jetbrains-mono-600.woff2') format('woff2');}
|
||||
|
||||
@theme {
|
||||
/* Couleurs de base */
|
||||
--color-bg: #0A0A0F;
|
||||
--color-surface: #111118;
|
||||
--color-surface-2: #16161F;
|
||||
--color-border: #1F1F2C;
|
||||
|
||||
/* Accents néon */
|
||||
--color-cyan: #00F0FF;
|
||||
--color-acid: #C6FF00; /* free party strobe */
|
||||
--color-magenta: #FF2A6D;
|
||||
--color-red: #FF0040; /* LED live / now playing */
|
||||
--color-violet: #B967FF;
|
||||
--color-ice: #D1F7FF;
|
||||
|
||||
/* Texte */
|
||||
--color-text: #E0E0FF;
|
||||
--color-text-muted: #8A8FA8;
|
||||
--color-text-dim: #5A5F78;
|
||||
|
||||
/* Typographies */
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--font-display: "Space Grotesk", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
--font-stencil: "Bebas Neue", "Impact", sans-serif;
|
||||
/* ---------- Variables de thème ---------- */
|
||||
:root{
|
||||
--bg:#09090b; --surface:#18181b; --surface-2:rgba(24,24,27,.55); --border:#27272a; --border-soft:#1c1c1f;
|
||||
--text:#f4f4f5; --text-muted:#a1a1aa; --text-dim:#52525b;
|
||||
--accent:#34d399; --accent-solid:#059669; --accent-hover:#10b981; --coffee:#f59e0b;
|
||||
--info:#7dd3fc; --warn:#fcd34d; --danger:#f87171;
|
||||
--halo:.12; --grain:.025; --shadow:0 24px 48px -20px rgba(0,0,0,.65);
|
||||
--font-sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
--font-mono:"JetBrains Mono",ui-monospace,monospace;
|
||||
--maxw:1120px; --header-h:88px;
|
||||
}
|
||||
html[data-theme="light"]{
|
||||
--bg:#fafafa; --surface:#ffffff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-soft:#ececee;
|
||||
--text:#18181b; --text-muted:#52525b; --text-dim:#a1a1aa;
|
||||
--accent:#059669; --accent-solid:#059669; --accent-hover:#047857; --coffee:#d97706;
|
||||
--info:#0284c7; --warn:#d97706; --danger:#dc2626;
|
||||
--halo:.06; --grain:.02; --shadow:0 18px 38px -22px rgba(24,24,27,.22);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
/* ---------- Base ---------- */
|
||||
*{box-sizing:border-box;}
|
||||
html{scroll-behavior:smooth;scroll-padding-top:var(--header-h);}
|
||||
html,body{margin:0;padding:0;background:var(--bg);}
|
||||
body{font-family:var(--font-sans);color:var(--text);-webkit-font-smoothing:antialiased;overflow-x:hidden;line-height:1.5;}
|
||||
a{color:inherit;text-decoration:none;}
|
||||
button{font-family:inherit;border:0;background:none;cursor:pointer;}
|
||||
img{max-width:100%;}
|
||||
h1,h2,h3,h4,p,ul{margin:0;}
|
||||
*,*::before,*::after{transition:background-color .22s ease,border-color .22s ease,color .22s ease,fill .22s ease;}
|
||||
::selection{background:var(--accent);color:var(--bg);}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:8px;}
|
||||
.mono{font-family:var(--font-mono);}
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||
|
||||
/* ---------- Animations ---------- */
|
||||
@keyframes jl-pulse{0%,100%{box-shadow:0 0 0 0 rgba(52,211,153,.55);}50%{box-shadow:0 0 0 5px rgba(52,211,153,0);}}
|
||||
@keyframes jl-blink{0%,49%{opacity:1;}50%,100%{opacity:0;}}
|
||||
|
||||
/* ---------- Overlays décoratifs (halo + grain) ---------- */
|
||||
.halo{position:fixed;top:-40px;left:50%;transform:translateX(-50%);width:1100px;max-width:140vw;height:560px;background:radial-gradient(50% 60% at 50% 0%,var(--accent) 0%,transparent 70%);opacity:var(--halo);pointer-events:none;z-index:0;filter:blur(18px);}
|
||||
.grain{position:fixed;inset:0;background-image:radial-gradient(var(--text) 1px,transparent 1px);background-size:34px 34px;opacity:var(--grain);pointer-events:none;z-index:0;-webkit-mask-image:linear-gradient(to bottom,#000,transparent 70%);mask-image:linear-gradient(to bottom,#000,transparent 70%);}
|
||||
.page{position:relative;z-index:1;}
|
||||
|
||||
/* ---------- Structure de page ---------- */
|
||||
.container{max-width:var(--maxw);margin:0 auto;padding-left:24px;padding-right:24px;}
|
||||
.section{padding-top:clamp(56px,8vw,96px);padding-bottom:clamp(56px,8vw,96px);}
|
||||
.eyebrow{font-family:var(--font-mono);font-size:13px;color:var(--accent);margin-bottom:12px;}
|
||||
.h2{font-size:clamp(28px,4vw,40px);font-weight:700;letter-spacing:-.03em;color:var(--text);}
|
||||
.label-mono{font-family:var(--font-mono);font-size:12px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;}
|
||||
|
||||
/* ---------- Apparition au scroll ---------- */
|
||||
.reveal{opacity:0;transform:translateY(22px);transition:opacity .7s ease,transform .7s ease;}
|
||||
.reveal.is-visible{opacity:1;transform:none;}
|
||||
|
||||
/* ---------- Icônes (fill/stroke posés en attribut sur chaque <svg>) ---------- */
|
||||
.ic{flex:none;}
|
||||
|
||||
/* ---------- En-tête / navigation ---------- */
|
||||
.site-header{position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);background:var(--surface-2);border-bottom:1px solid var(--border);}
|
||||
.nav{max-width:var(--maxw);margin:0 auto;padding:13px 24px;display:flex;align-items:center;gap:18px;}
|
||||
.nav-logo{display:flex;align-items:center;flex:none;}
|
||||
.nav-logo img{height:29px;width:auto;display:block;}
|
||||
.nav-links{display:flex;align-items:center;gap:6px;margin-left:6px;}
|
||||
.nav-link{font-family:var(--font-mono);font-size:13px;color:var(--text-muted);padding:7px 11px;border-radius:8px;}
|
||||
.nav-link:hover{color:var(--text);}
|
||||
.nav-link.is-active{color:var(--accent);}
|
||||
.nav-actions{margin-left:auto;display:flex;align-items:center;gap:9px;}
|
||||
|
||||
.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:9px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-muted);}
|
||||
.btn-icon:hover{color:var(--accent);border-color:var(--accent);}
|
||||
|
||||
/* Bascule d'icône thème : soleil en sombre, lune en clair (CSS pur) */
|
||||
.icon-moon{display:none;}
|
||||
.icon-sun{display:block;}
|
||||
html[data-theme="light"] .icon-sun{display:none;}
|
||||
html[data-theme="light"] .icon-moon{display:block;}
|
||||
|
||||
.btn-coffee{display:inline-flex;align-items:center;gap:7px;padding:8px 13px;border-radius:9px;border:1px solid var(--coffee);color:var(--coffee);font-size:13px;font-weight:600;}
|
||||
.btn-coffee:hover{background:color-mix(in srgb,var(--coffee) 12%,transparent);transform:translateY(-1px);}
|
||||
|
||||
.burger{display:none;align-items:center;justify-content:center;width:40px;height:40px;border-radius:9px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);}
|
||||
|
||||
/* Menu mobile (déplié sous l'en-tête) */
|
||||
.mobile-menu{display:none;border-top:1px solid var(--border);background:var(--surface);padding:14px 24px 18px;flex-direction:column;gap:4px;}
|
||||
.mobile-menu.is-open{display:flex;}
|
||||
.mobile-link{font-family:var(--font-mono);font-size:15px;color:var(--text);padding:11px 4px;border-bottom:1px solid var(--border-soft);}
|
||||
.mobile-cta{display:flex;gap:10px;margin-top:14px;}
|
||||
.mobile-cta a{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px;border-radius:9px;font-size:14px;font-weight:600;}
|
||||
.mobile-cta .m-github{border:1px solid var(--border);color:var(--text);}
|
||||
.mobile-cta .m-coffee{border:1px solid var(--coffee);color:var(--coffee);}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:clamp(32px,5vw,56px);align-items:center;}
|
||||
.badge{display:inline-flex;align-items:center;gap:8px;padding:6px 13px;border:1px solid var(--border);border-radius:999px;background:var(--surface-2);font-family:var(--font-mono);font-size:12px;color:var(--text-muted);}
|
||||
.badge-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:jl-pulse 2.2s infinite;}
|
||||
.hero h1{font-size:clamp(42px,7.2vw,70px);font-weight:800;letter-spacing:-.04em;line-height:1;margin-top:20px;color:var(--text);}
|
||||
.hero-role{font-size:clamp(18px,2.4vw,22px);font-weight:600;color:var(--accent);margin-top:12px;}
|
||||
.hero-lede{font-size:17px;color:var(--text-muted);margin-top:20px;max-width:48ch;line-height:1.62;}
|
||||
.cta-row{display:flex;flex-wrap:wrap;gap:12px;margin-top:28px;}
|
||||
.btn{display:inline-flex;align-items:center;gap:9px;padding:13px 21px;border-radius:11px;font-weight:600;font-size:15px;}
|
||||
.btn-primary{background:var(--accent-solid);color:#fff;border:1px solid var(--accent-solid);}
|
||||
.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px);}
|
||||
.btn-secondary{background:transparent;color:var(--text);border:1px solid var(--border);}
|
||||
.btn-secondary:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-1px);}
|
||||
.social-row{display:flex;gap:10px;margin-top:26px;}
|
||||
.social-link{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:10px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-muted);}
|
||||
.social-link:hover{color:var(--accent);border-color:var(--accent);transform:translateY(-2px);}
|
||||
|
||||
/* Mockup terminal */
|
||||
.terminal{border:1px solid var(--border);border-radius:14px;background:var(--surface);box-shadow:var(--shadow);overflow:hidden;}
|
||||
.terminal-bar{display:flex;align-items:center;gap:7px;padding:12px 14px;border-bottom:1px solid var(--border);background:var(--surface-2);}
|
||||
.terminal-bar .dot{width:11px;height:11px;border-radius:50%;}
|
||||
.dot-r{background:#f87171;} .dot-y{background:#fbbf24;} .dot-g{background:#34d399;}
|
||||
.terminal-label{margin-left:8px;font-family:var(--font-mono);font-size:12px;color:var(--text-dim);}
|
||||
.terminal-avatar{margin-left:auto;width:28px;height:28px;border-radius:50%;object-fit:cover;border:1px solid var(--border);}
|
||||
.terminal-body{margin:0;padding:18px 20px;font-family:var(--font-mono);font-size:13.5px;line-height:1.72;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;min-height:312px;}
|
||||
.caret{display:inline-block;width:8px;height:15px;background:var(--accent);vertical-align:-2px;margin-left:2px;animation:jl-blink 1.05s steps(1) infinite;}
|
||||
|
||||
/* ---------- À propos (portrait + texte) + stats ---------- */
|
||||
.about-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:clamp(28px,5vw,48px);align-items:center;}
|
||||
@media(min-width:760px){.about-grid{grid-template-columns:288px 1fr;}}
|
||||
.about-portrait{position:relative;border-radius:16px;overflow:hidden;border:1px solid var(--border);box-shadow:var(--shadow);max-width:320px;justify-self:center;}
|
||||
.about-portrait img{display:block;width:100%;height:auto;}
|
||||
.about-portrait::after{content:"";position:absolute;inset:0;border-radius:16px;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 22%,transparent);pointer-events:none;}
|
||||
.about-h2{margin:0 0 18px;max-width:20ch;}
|
||||
.about-p{font-size:17px;color:var(--text-muted);line-height:1.65;max-width:62ch;}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-top:36px;}
|
||||
.stat{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:20px;}
|
||||
.stat-n{font-family:var(--font-mono);font-size:clamp(24px,3vw,30px);font-weight:700;color:var(--accent);}
|
||||
.stat-l{font-size:13px;color:var(--text-muted);margin-top:6px;}
|
||||
|
||||
/* ---------- Projets ---------- */
|
||||
.grid-projects{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;}
|
||||
.grid-projects.is-other{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));}
|
||||
.card{display:flex;flex-direction:column;border:1px solid var(--border);border-radius:14px;background:var(--surface-2);overflow:hidden;transition:transform .25s ease,border-color .25s ease,box-shadow .25s ease;}
|
||||
.card:hover{transform:translateY(-4px);border-color:var(--accent);box-shadow:var(--shadow);}
|
||||
.card-media{position:relative;height:168px;overflow:hidden;}
|
||||
.is-other .card-media{height:140px;}
|
||||
.card-media img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.card-mono{height:100%;display:flex;align-items:center;justify-content:center;background:radial-gradient(120% 130% at 0% 0%,color-mix(in srgb,var(--accent) 18%,transparent),transparent 58%),var(--surface);}
|
||||
.card-mono span{font-family:var(--font-mono);font-size:44px;font-weight:700;color:var(--accent);opacity:.92;}
|
||||
.is-other .card-mono span{font-size:38px;}
|
||||
.card-body{padding:18px 18px 20px;display:flex;flex-direction:column;gap:11px;flex:1;}
|
||||
.is-other .card-body{padding:16px 16px 18px;gap:10px;}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;}
|
||||
.status{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;}
|
||||
.status-dot{width:7px;height:7px;border-radius:50%;}
|
||||
.ext-link{color:var(--text-muted);display:inline-flex;}
|
||||
.ext-link:hover{color:var(--accent);}
|
||||
.card-title{font-size:19px;font-weight:700;letter-spacing:-.02em;color:var(--text);}
|
||||
.is-other .card-title{font-size:17px;}
|
||||
.card-sub{font-family:var(--font-mono);font-size:12px;color:var(--accent);}
|
||||
.card-desc{font-size:14px;color:var(--text-muted);line-height:1.55;}
|
||||
.is-other .card-desc{font-size:13.5px;}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:6px;}
|
||||
.chip{font-family:var(--font-mono);font-size:11px;padding:4px 9px;border:1px solid var(--border);border-radius:999px;color:var(--text-muted);background:var(--bg);}
|
||||
|
||||
/* ---------- Parcours (timeline + formation) ---------- */
|
||||
.timeline{position:relative;padding-left:70px;}
|
||||
.timeline-line{position:absolute;left:23px;top:6px;bottom:6px;width:2px;background:var(--border);}
|
||||
.exp{position:relative;margin-bottom:34px;}
|
||||
.exp:last-child{margin-bottom:0;}
|
||||
.exp-logo{position:absolute;left:-70px;top:0;width:48px;height:48px;border-radius:50%;background:#fff;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;box-shadow:0 0 0 5px var(--bg);}
|
||||
.exp-logo img{width:34px;height:34px;object-fit:contain;}
|
||||
.exp-card{border:1px solid var(--border);border-radius:14px;background:var(--surface-2);padding:18px 20px;}
|
||||
.exp-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 12px;}
|
||||
.exp-role{font-size:17px;font-weight:700;color:var(--text);}
|
||||
.exp-company{display:inline-flex;align-items:center;gap:7px;font-weight:600;color:var(--accent);font-size:14px;}
|
||||
.exp-period{font-family:var(--font-mono);font-size:12px;color:var(--text-dim);margin-top:7px;}
|
||||
.exp-desc{font-size:14px;color:var(--text-muted);line-height:1.55;margin-top:12px;}
|
||||
.exp-missions{margin-top:12px;padding-left:18px;display:flex;flex-direction:column;gap:5px;}
|
||||
.exp-missions li{font-size:13.5px;color:var(--text-muted);line-height:1.45;}
|
||||
.exp-card .chips{margin-top:14px;}
|
||||
.formation-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(238px,1fr));gap:14px;}
|
||||
.formation-card{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:18px;}
|
||||
.formation-period{font-family:var(--font-mono);font-size:11px;color:var(--accent);margin-bottom:9px;}
|
||||
.formation-title{font-size:15px;font-weight:700;margin-bottom:4px;color:var(--text);line-height:1.3;}
|
||||
.formation-school{font-size:13px;color:var(--text-muted);}
|
||||
.formation-desc{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin-top:10px;}
|
||||
|
||||
/* ---------- Stack & compétences ---------- */
|
||||
.stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(284px,1fr));gap:16px;}
|
||||
.stack-grid.is-secondary{grid-template-columns:repeat(auto-fit,minmax(260px,1fr));}
|
||||
.stack-group{border:1px solid var(--border);border-radius:14px;background:var(--surface-2);padding:18px;}
|
||||
.is-secondary .stack-group{border:1px solid var(--border-soft);background:transparent;}
|
||||
.stack-group-label{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.tiles{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;}
|
||||
.is-secondary .tiles{gap:7px;margin-top:13px;}
|
||||
.tile{display:flex;align-items:center;gap:9px;padding:7px 11px 7px 9px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text-muted);}
|
||||
.is-secondary .tile{gap:8px;padding:6px 10px 6px 8px;border-radius:9px;}
|
||||
.tile .ic{width:18px;height:18px;}
|
||||
.is-secondary .tile .ic{width:15px;height:15px;}
|
||||
.tile-mono{width:24px;height:24px;border-radius:7px;background:var(--accent-solid);color:#fff;display:flex;align-items:center;justify-content:center;flex:none;font-family:var(--font-mono);font-size:11px;font-weight:600;}
|
||||
.is-secondary .tile-mono{width:21px;height:21px;border-radius:6px;font-size:10px;}
|
||||
.tile-name{font-family:var(--font-mono);font-size:13px;color:var(--text);white-space:nowrap;}
|
||||
.is-secondary .tile-name{font-size:12.5px;color:var(--text-muted);}
|
||||
|
||||
.soft{margin-top:34px;display:flex;flex-direction:column;gap:16px;}
|
||||
.soft-label{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:11px;}
|
||||
.pills{display:flex;flex-wrap:wrap;gap:8px;}
|
||||
.pill{font-size:13px;padding:6px 12px;border:1px solid var(--border);border-radius:999px;color:var(--text-muted);background:var(--surface-2);}
|
||||
|
||||
/* ---------- Centres d'intérêt ---------- */
|
||||
.interests{display:grid;grid-template-columns:repeat(auto-fit,minmax(216px,1fr));gap:14px;}
|
||||
.interest{display:flex;flex-direction:column;gap:11px;padding:20px;border:1px solid var(--border);border-radius:14px;background:var(--surface-2);transition:border-color .25s,transform .25s;}
|
||||
.interest:hover{border-color:var(--accent);transform:translateY(-3px);}
|
||||
.interest-ic{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:10px;background:var(--bg);border:1px solid var(--border);color:var(--accent);}
|
||||
.interest-name{font-weight:600;font-size:15px;color:var(--text);}
|
||||
.interest-note{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);}
|
||||
|
||||
/* ---------- Contact ---------- */
|
||||
.contact-card{border:1px solid var(--border);border-radius:18px;background:var(--surface-2);padding:clamp(28px,5vw,52px);text-align:center;}
|
||||
.contact-h2{font-size:clamp(28px,4.6vw,44px);font-weight:800;letter-spacing:-.03em;margin:0 auto;color:var(--text);max-width:18ch;}
|
||||
.contact-p{font-size:16px;color:var(--text-muted);margin:18px auto 0;max-width:52ch;line-height:1.6;}
|
||||
.contact-cta{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;margin-top:30px;}
|
||||
.btn-email{display:inline-flex;align-items:center;gap:9px;padding:14px 24px;border-radius:11px;background:var(--accent-solid);color:#fff;font-weight:600;font-size:15px;border:1px solid var(--accent-solid);}
|
||||
.btn-email:hover{background:var(--accent-hover);transform:translateY(-1px);}
|
||||
.contact-social{display:flex;gap:10px;justify-content:center;margin-top:20px;}
|
||||
.contact-social .social-link{background:var(--bg);}
|
||||
.echo{font-family:var(--font-mono);font-size:13px;color:var(--text-dim);margin-top:28px;}
|
||||
.echo b{color:var(--accent);font-weight:400;}
|
||||
.coffee-box{margin-top:34px;padding-top:30px;border-top:1px solid var(--border);display:flex;flex-direction:column;align-items:center;gap:16px;}
|
||||
.coffee-box p{font-size:14.5px;color:var(--text-muted);max-width:46ch;line-height:1.6;}
|
||||
.btn-coffee-lg{display:inline-flex;align-items:center;gap:9px;padding:12px 22px;border-radius:11px;background:transparent;color:var(--coffee);font-weight:600;font-size:15px;border:1px solid var(--coffee);}
|
||||
.btn-coffee-lg:hover{background:color-mix(in srgb,var(--coffee) 12%,transparent);transform:translateY(-1px);}
|
||||
|
||||
/* ---------- Pied de page ---------- */
|
||||
.site-footer{border-top:1px solid var(--border);}
|
||||
.footer-inner{max-width:var(--maxw);margin:0 auto;padding:28px 24px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;}
|
||||
.footer-brand{display:flex;align-items:center;gap:12px;}
|
||||
.footer-brand img{height:26px;width:auto;display:block;}
|
||||
.footer-brand span{font-family:var(--font-mono);font-size:13px;color:var(--text-muted);}
|
||||
.footer-credit{font-family:var(--font-mono);font-size:12px;color:var(--text-dim);}
|
||||
|
||||
/* ---------- Responsive : repli nav < 820px ---------- */
|
||||
@media (max-width:819px){
|
||||
.nav-links,.btn-coffee--header,.btn-github--header{display:none;}
|
||||
.burger{display:inline-flex;}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.timeline{padding-left:58px;}
|
||||
.exp-logo{left:-58px;width:42px;height:42px;}
|
||||
.exp-logo img{width:28px;height:28px;}
|
||||
.timeline-line{left:20px;}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-acid);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--color-cyan), var(--color-acid));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Glow utilities
|
||||
========================================================================= */
|
||||
|
||||
@utility glow-cyan {
|
||||
box-shadow:
|
||||
0 0 8px rgba(0, 240, 255, 0.4),
|
||||
0 0 20px rgba(0, 240, 255, 0.25),
|
||||
0 0 40px rgba(0, 240, 255, 0.12);
|
||||
}
|
||||
@utility glow-acid {
|
||||
box-shadow:
|
||||
0 0 8px rgba(198, 255, 0, 0.5),
|
||||
0 0 20px rgba(198, 255, 0, 0.3),
|
||||
0 0 40px rgba(198, 255, 0, 0.15);
|
||||
}
|
||||
@utility glow-magenta {
|
||||
box-shadow:
|
||||
0 0 8px rgba(255, 42, 109, 0.4),
|
||||
0 0 20px rgba(255, 42, 109, 0.25),
|
||||
0 0 40px rgba(255, 42, 109, 0.12);
|
||||
}
|
||||
@utility glow-red {
|
||||
box-shadow:
|
||||
0 0 6px rgba(255, 0, 64, 0.6),
|
||||
0 0 16px rgba(255, 0, 64, 0.3);
|
||||
}
|
||||
|
||||
@utility glow-text-cyan {
|
||||
text-shadow: 0 0 6px rgba(0, 240, 255, 0.8), 0 0 20px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
@utility glow-text-acid {
|
||||
text-shadow: 0 0 6px rgba(198, 255, 0, 0.85), 0 0 22px rgba(198, 255, 0, 0.45);
|
||||
}
|
||||
@utility glow-text-magenta {
|
||||
text-shadow: 0 0 6px rgba(255, 42, 109, 0.8), 0 0 20px rgba(255, 42, 109, 0.4);
|
||||
}
|
||||
|
||||
@utility neon-border-cyan {
|
||||
border: 1px solid var(--color-cyan);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 240, 255, 0.3),
|
||||
0 0 20px rgba(0, 240, 255, 0.2),
|
||||
inset 0 0 12px rgba(0, 240, 255, 0.08);
|
||||
}
|
||||
@utility neon-border-acid {
|
||||
border: 1px solid var(--color-acid);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(198, 255, 0, 0.35),
|
||||
0 0 20px rgba(198, 255, 0, 0.2),
|
||||
inset 0 0 12px rgba(198, 255, 0, 0.08);
|
||||
}
|
||||
@utility neon-border-magenta {
|
||||
border: 1px solid var(--color-magenta);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 42, 109, 0.3),
|
||||
0 0 20px rgba(255, 42, 109, 0.2),
|
||||
inset 0 0 12px rgba(255, 42, 109, 0.08);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Free party utilities
|
||||
========================================================================= */
|
||||
|
||||
@utility stencil {
|
||||
font-family: var(--font-stencil);
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Sticker style (pour les overlays "flyer" collés) */
|
||||
@utility sticker {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
/* Warehouse background — gradient + grain + UV spots (pas encore les spots mobiles, juste la base) */
|
||||
@utility warehouse-bg {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 15% 20%, rgba(0, 240, 255, 0.1), transparent 60%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 70%, rgba(255, 42, 109, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 70% 50% at 50% 100%, rgba(198, 255, 0, 0.05), transparent 60%),
|
||||
var(--color-bg);
|
||||
}
|
||||
|
||||
/* Aurora (kept as alias for warehouse) */
|
||||
@utility aurora-bg {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 15% 20%, rgba(0, 240, 255, 0.1), transparent 60%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 70%, rgba(255, 42, 109, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 70% 50% at 50% 100%, rgba(198, 255, 0, 0.05), transparent 60%),
|
||||
var(--color-bg);
|
||||
}
|
||||
|
||||
/* Scanlines CRT subtiles (à appliquer sur un pseudo-élément ou overlay fixe) */
|
||||
@utility scanlines {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.015) 0px,
|
||||
rgba(255, 255, 255, 0.015) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Keyframes
|
||||
========================================================================= */
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
45%, 55% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes pulse-neon {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(0, 240, 255, 0.4), 0 0 20px rgba(0, 240, 255, 0.25); }
|
||||
50% { box-shadow: 0 0 16px rgba(0, 240, 255, 0.7), 0 0 32px rgba(0, 240, 255, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes pulse-led-green {
|
||||
0%, 100% {
|
||||
background: #00FF6A;
|
||||
box-shadow: 0 0 8px rgba(0, 255, 106, 0.7), 0 0 16px rgba(0, 255, 106, 0.35);
|
||||
}
|
||||
50% {
|
||||
background: #00FF6A;
|
||||
box-shadow: 0 0 14px rgba(0, 255, 106, 1), 0 0 28px rgba(0, 255, 106, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-led-red {
|
||||
0%, 100% {
|
||||
background: var(--color-red);
|
||||
box-shadow: 0 0 6px rgba(255, 0, 64, 0.7), 0 0 14px rgba(255, 0, 64, 0.35);
|
||||
}
|
||||
50% {
|
||||
background: var(--color-red);
|
||||
box-shadow: 0 0 12px rgba(255, 0, 64, 1), 0 0 22px rgba(255, 0, 64, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-skew {
|
||||
0%, 100% { transform: translate(-50%, -50%) skew(0deg); }
|
||||
20% { transform: translate(-50%, -50%) skew(3deg, -1deg); }
|
||||
40% { transform: translate(-50%, -50%) skew(-2deg, 1deg); }
|
||||
60% { transform: translate(-50%, -50%) skew(2deg, 0deg); }
|
||||
80% { transform: translate(-50%, -50%) skew(-3deg, 2deg); }
|
||||
}
|
||||
|
||||
@keyframes grain {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-5%, -10%); }
|
||||
20% { transform: translate(-15%, 5%); }
|
||||
30% { transform: translate(7%, -25%); }
|
||||
40% { transform: translate(-5%, 25%); }
|
||||
50% { transform: translate(-15%, 10%); }
|
||||
60% { transform: translate(15%, 0%); }
|
||||
70% { transform: translate(0%, 15%); }
|
||||
80% { transform: translate(3%, 35%); }
|
||||
90% { transform: translate(-10%, 10%); }
|
||||
}
|
||||
|
||||
@utility animate-flicker { animation: flicker 3s infinite; }
|
||||
@utility animate-pulse-neon { animation: pulse-neon 2s ease-in-out infinite; }
|
||||
@utility animate-led-green { animation: pulse-led-green 1.4s ease-in-out infinite; }
|
||||
@utility animate-led-red { animation: pulse-led-red 1s ease-in-out infinite; }
|
||||
@utility animate-grain { animation: grain 8s steps(10) infinite; }
|
||||
|
||||
/* =========================================================================
|
||||
Composants de base
|
||||
========================================================================= */
|
||||
|
||||
@layer components {
|
||||
/* CTA principal : acid yellow (free party flyer) */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.75rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-acid);
|
||||
color: var(--color-bg);
|
||||
border: 1px solid var(--color-acid);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 0 20px rgba(198, 255, 0, 0.35);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 30px rgba(198, 255, 0, 0.7),
|
||||
0 0 60px rgba(198, 255, 0, 0.3);
|
||||
}
|
||||
|
||||
/* CTA ghost : cyan UV */
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.75rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
color: var(--color-cyan);
|
||||
border: 1px solid var(--color-cyan);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--color-cyan);
|
||||
color: var(--color-bg);
|
||||
box-shadow: 0 0 30px rgba(0, 240, 255, 0.6);
|
||||
}
|
||||
|
||||
.card-neon {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-neon:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 240, 255, 0.4);
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
border: 1px solid rgba(0, 240, 255, 0.25);
|
||||
color: var(--color-cyan);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* LED indicators (circles à côté d'un label) */
|
||||
.led {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.led-green { background: #00FF6A; box-shadow: 0 0 8px #00FF6A; }
|
||||
.led-red { background: var(--color-red); box-shadow: 0 0 8px var(--color-red); }
|
||||
.led-cyan { background: var(--color-cyan); box-shadow: 0 0 8px var(--color-cyan); }
|
||||
.led-acid { background: var(--color-acid); box-shadow: 0 0 8px var(--color-acid); }
|
||||
|
||||
.font-display { font-family: var(--font-display); }
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
.font-stencil { font-family: var(--font-stencil); letter-spacing: 0.04em; }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
View Transitions (crossfade EQ entre pages)
|
||||
========================================================================= */
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: fade-blur-out 0.3s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: fade-blur-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fade-blur-out {
|
||||
from { opacity: 1; filter: blur(0); }
|
||||
to { opacity: 0; filter: blur(8px); }
|
||||
}
|
||||
@keyframes fade-blur-in {
|
||||
from { opacity: 0; filter: blur(8px); transform: translateY(8px); }
|
||||
to { opacity: 1; filter: blur(0); transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Accessibility
|
||||
========================================================================= */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none !important;
|
||||
}
|
||||
/* ---------- Respect de prefers-reduced-motion ---------- */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
html{scroll-behavior:auto;}
|
||||
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;}
|
||||
.reveal{opacity:1;transform:none;}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user