first commit

This commit is contained in:
Johan LEROY
2026-04-21 14:14:03 +02:00
commit 9e9bc0f23d
124 changed files with 13294 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
---
// 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>

146
src/components/Footer.astro Normal file
View File

@@ -0,0 +1,146 @@
---
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">
&gt; home
</a>
</li>
<li>
<a href="/projets" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
&gt; projets
</a>
</li>
<li>
<a href="/experience" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
&gt; experience
</a>
</li>
<li>
<a href="/formations" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
&gt; 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"
>
&gt; {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"
>
&gt; 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"
>
&gt; gitea ↗
</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"
>
&gt; 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)]">&lt;/&gt;</span> built with astro · dark mode only
</span>
</div>
</div>
</footer>

244
src/components/Header.astro Normal file
View File

@@ -0,0 +1,244 @@
---
import type { Profile } from '../content/schemas';
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 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:persist
>
<!-- 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&nbsp;air
</span>
)
}
</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>
<!-- 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"
>
<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>
</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"
>
<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>
</button>
</div>
</div>
<!-- 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>
</header>
<script>
// 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') as HTMLButtonElement | null;
const onIcon = soundBtn?.querySelector('.sound-on') as SVGElement | null;
const offIcon = soundBtn?.querySelector('.sound-off') as SVGElement | null;
function applySoundState(on: boolean) {
if (!soundBtn || !onIcon || !offIcon) return;
soundBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
onIcon.classList.toggle('hidden', !on);
offIcon.classList.toggle('hidden', on);
(window as unknown as { __soundOn?: boolean }).__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>

View File

@@ -0,0 +1,94 @@
---
import type { Project } from '../content/schemas';
interface Props {
project: Project;
}
const { project } = Astro.props;
const isArchived = project.status === 'archived';
const isPrivate = project.status === 'private';
const isWIP = project.status === 'wip';
const statusLabel =
project.status === 'live'
? 'Live'
: project.status === 'archived'
? 'Archivé'
: project.status === 'private'
? 'Privé'
: 'WIP';
const Tag = project.url && !isArchived && !isPrivate ? 'a' : 'div';
const tagProps = project.url && !isArchived && !isPrivate
? { href: project.url, target: '_blank', rel: 'noopener noreferrer' }
: {};
---
<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']}
/>
) : (
<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="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>
<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>

31
src/components/SEO.astro Normal file
View File

@@ -0,0 +1,31 @@
---
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} />

View File

@@ -0,0 +1,98 @@
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>
);
}

View File

@@ -0,0 +1,99 @@
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>
</>
);
}

View File

@@ -0,0 +1,131 @@
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;
}

View File

@@ -0,0 +1,71 @@
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>
);
}

View File

@@ -0,0 +1,139 @@
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 md:grid-cols-[1fr_auto_1fr] gap-10 md:gap-20 bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-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, i) => (
<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, i) => (
<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>
);
}

View File

@@ -0,0 +1,156 @@
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-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>
);
}

View File

@@ -0,0 +1,42 @@
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>
);
}

View File

@@ -0,0 +1,36 @@
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>
);
}

View File

@@ -0,0 +1,174 @@
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"
>
&gt;
</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 md:grid-cols-[200px_1fr] 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>
);
}

View File

@@ -0,0 +1,212 @@
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-[280px] sm: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-10 -left-2 stencil 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>
);
}

View File

@@ -0,0 +1,66 @@
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>
);
}

View File

@@ -0,0 +1,26 @@
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);
return () => {
cancelAnimationFrame(rafId);
lenis.destroy();
};
}, []);
return null;
}

View File

@@ -0,0 +1,54 @@
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>
);
}

View File

@@ -0,0 +1,75 @@
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>
);
}

View File

@@ -0,0 +1,28 @@
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>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const FormationSchema = z.object({
id: z.string(),
title: z.string(),
school: z.string(),
schoolUrl: z.string().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>;

View File

@@ -0,0 +1,27 @@
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;

View File

@@ -0,0 +1,15 @@
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>;

View File

@@ -0,0 +1,35 @@
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.string().email().optional(),
github: z.string().url().optional(),
gitea: z.string().url().optional(),
linkedin: z.string().url().optional(),
website: z.string().url().optional(),
}),
cv: z.string().optional(),
});
export type Profile = z.infer<typeof ProfileSchema>;

View File

@@ -0,0 +1,22 @@
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.string().url().optional(),
repoUrl: z.string().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>;

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const SiteSchema = z.object({
title: z.string(),
description: z.string(),
url: z.string().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>;

View File

@@ -0,0 +1,22 @@
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>;

View File

@@ -0,0 +1,51 @@
---
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>

46
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,46 @@
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) ?? 'dev-secret-change-me-in-production';
const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
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(SECRET_KEY);
}
export async function verifySession(token: string | undefined): Promise<boolean> {
if (!token) return false;
try {
await jwtVerify(token, SECRET_KEY);
return true;
} catch {
return false;
}
}
export function hashPasswordCLI(password: string): string {
return bcrypt.hashSync(password, 12);
}

40
src/lib/content.ts Normal file
View File

@@ -0,0 +1,40 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { CONTENT_SCHEMAS, type ContentKey } from '../content/schemas';
import type { z } from 'zod';
const CONTENT_DIR = join(process.cwd(), 'public', 'content');
type ContentData<K extends ContentKey> = z.infer<(typeof CONTENT_SCHEMAS)[K]>;
export async function loadContent<K extends ContentKey>(key: K): Promise<ContentData<K>> {
const filePath = join(CONTENT_DIR, `${key}.json`);
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 = join(CONTENT_DIR, `${key}.json`);
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 };
}

28
src/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
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();
});

45
src/pages/404.astro Normal file
View File

@@ -0,0 +1,45 @@
---
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)]">&gt;</span> frequency not found in crate
</p>
<p>
<span class="text-[color:var(--color-magenta)]">&gt;</span> rejoin the main feed
</p>
</div>
<a href="/" class="btn-primary">
&lt;&lt; retour au sound system
</a>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,114 @@
---
export const prerender = false;
import '../../../styles/global.css';
import { loadContent } from '../../../lib/content';
import { CONTENT_SCHEMAS, type ContentKey } from '../../../content/schemas';
const { section } = Astro.params;
if (!section || !(section in CONTENT_SCHEMAS)) {
return Astro.redirect('/admin');
}
const key = section as ContentKey;
const data = await loadContent(key);
const jsonString = JSON.stringify(data, null, 2);
---
<!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&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>
<div class="flex items-center gap-3">
<span id="save-status" class="text-xs font-mono text-[color:var(--color-text-dim)]"></span>
<button id="format-btn" class="btn-ghost text-xs py-1.5">Formater</button>
<button id="save-btn" class="btn-primary text-xs py-1.5">Enregistrer</button>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-6">
<p class="text-xs font-mono text-[color:var(--color-text-dim)] mb-2">
Édition libre en JSON. Le contenu est validé contre le schéma Zod avant sauvegarde.
</p>
<textarea
id="editor"
class="w-full h-[calc(100vh-200px)] 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"
spellcheck="false"
>{jsonString}</textarea>
</main>
<script define:vars={{ section }}>
const editor = document.getElementById('editor');
const saveBtn = document.getElementById('save-btn');
const formatBtn = document.getElementById('format-btn');
const status = document.getElementById('save-status');
formatBtn.addEventListener('click', () => {
try {
const parsed = JSON.parse(editor.value);
editor.value = JSON.stringify(parsed, null, 2);
status.textContent = 'formaté';
status.style.color = 'var(--color-cyan)';
} catch (e) {
status.textContent = 'JSON invalide';
status.style.color = 'var(--color-magenta)';
}
});
saveBtn.addEventListener('click', async () => {
status.textContent = 'enregistrement…';
status.style.color = 'var(--color-text-muted)';
let body;
try {
body = JSON.parse(editor.value);
} catch (e) {
status.textContent = 'JSON invalide';
status.style.color = 'var(--color-magenta)';
return;
}
const res = await fetch(`/api/content/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await res.json();
if (res.ok) {
status.textContent = '✓ enregistré';
status.style.color = 'var(--color-cyan)';
setTimeout(() => { status.textContent = ''; }, 3000);
} else {
status.textContent = `✗ ${result.message || result.error}`;
status.style.color = 'var(--color-magenta)';
}
});
editor.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
</script>
</body>
</html>

101
src/pages/admin/index.astro Normal file
View File

@@ -0,0 +1,101 @@
---
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 md: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>

View File

@@ -0,0 +1,71 @@
---
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>

View File

@@ -0,0 +1,35 @@
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' },
});
};

View File

@@ -0,0 +1,11 @@
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' },
});
};

View File

@@ -0,0 +1,51 @@
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) {
return new Response(
JSON.stringify({ error: 'load_failed', message: (err as Error).message }),
{ 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) {
return new Response(
JSON.stringify({ error: 'validation_failed', message: (err as Error).message }),
{ status: 400 },
);
}
};

41
src/pages/api/upload.ts Normal file
View File

@@ -0,0 +1,41 @@
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_DIR = join(process.cwd(), 'public', 'img');
const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']);
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) ?? '';
if (!(file instanceof File)) {
return new Response(JSON.stringify({ error: 'no_file' }), { status: 400 });
}
const ext = extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
return new Response(JSON.stringify({ error: 'invalid_extension' }), { status: 400 });
}
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
const targetDir = safeFolder ? join(UPLOAD_DIR, safeFolder) : UPLOAD_DIR;
await mkdir(targetDir, { recursive: true });
const filename = sanitize(file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(join(targetDir, filename), buffer);
const url = `/img/${safeFolder ? safeFolder + '/' : ''}${filename}`;
return new Response(JSON.stringify({ ok: true, url }), {
headers: { 'Content-Type': 'application/json' },
});
};

View File

@@ -0,0 +1,72 @@
---
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-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-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>

143
src/pages/formations.astro Normal file
View File

@@ -0,0 +1,143 @@
---
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-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)]">&gt;</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="relative group transition-transform duration-500 hover:rotate-0"
style={`transform: rotate(${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 md:grid-cols-[auto_1fr] gap-6">
<div class="font-mono text-[10px] uppercase tracking-widest space-y-2 md:border-r-2 md:border-dashed md:border-[#14130E]/30 md: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>

244
src/pages/index.astro Normal file
View File

@@ -0,0 +1,244 @@
---
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, site } = await loadAll();
---
<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="max-w-3xl">
<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>
&gt; frequency: 174 bpm · hardtek
</p>
<h1 class="stencil 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-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-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) => (
<a
href={btn.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>
</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 md: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 md: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-2 md:grid-cols-4 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>

69
src/pages/projets.astro Normal file
View File

@@ -0,0 +1,69 @@
---
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-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)]">&gt;</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>

373
src/styles/global.css Normal file
View File

@@ -0,0 +1,373 @@
@import "tailwindcss";
/* =========================================================================
PORTFOLIO — Cyberpunk Dark / Free Party v2
Palette acid : noir warehouse + cyan UV + acid yellow + magenta + LED rouge
========================================================================= */
@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;
}
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;
}
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;
}
}