first commit
This commit is contained in:
81
src/components/Ambient.astro
Normal file
81
src/components/Ambient.astro
Normal 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
146
src/components/Footer.astro
Normal 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">
|
||||
> home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/projets" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> projets
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/experience" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> experience
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/formations" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||
> formations
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="stencil uppercase tracking-[0.25em] text-xs text-[color:var(--color-acid)] mb-4">
|
||||
open mic
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm font-mono">
|
||||
{
|
||||
profile.socials.email && (
|
||||
<li>
|
||||
<a
|
||||
href={`mailto:${profile.socials.email}`}
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition break-all"
|
||||
>
|
||||
> {profile.socials.email}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.github && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> github ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.gitea && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.gitea}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> gitea ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
profile.socials.website && (
|
||||
<li>
|
||||
<a
|
||||
href={profile.socials.website}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||
>
|
||||
> portfolio ↗
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-6 border-t border-[color:var(--color-border)] flex flex-col md:flex-row justify-between items-center gap-2 text-[10px] text-[color:var(--color-text-dim)] font-mono uppercase tracking-widest"
|
||||
>
|
||||
<span>© {year} johan leroy · all rights reserved</span>
|
||||
<span>
|
||||
<span class="text-[color:var(--color-cyan)]"></></span> built with astro · dark mode only
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
244
src/components/Header.astro
Normal file
244
src/components/Header.astro
Normal 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 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>
|
||||
94
src/components/ProjectCard.astro
Normal file
94
src/components/ProjectCard.astro
Normal 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
31
src/components/SEO.astro
Normal 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} />
|
||||
98
src/components/islands/BootSequence.tsx
Normal file
98
src/components/islands/BootSequence.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/components/islands/Crate.tsx
Normal file
99
src/components/islands/Crate.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
src/components/islands/EasterEggs.tsx
Normal file
131
src/components/islands/EasterEggs.tsx
Normal 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;
|
||||
}
|
||||
71
src/components/islands/MagneticButton.tsx
Normal file
71
src/components/islands/MagneticButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/components/islands/PatchBay.tsx
Normal file
139
src/components/islands/PatchBay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/islands/RackModule.tsx
Normal file
156
src/components/islands/RackModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/islands/Reveal.tsx
Normal file
42
src/components/islands/Reveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/islands/RolesMorph.tsx
Normal file
36
src/components/islands/RolesMorph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
src/components/islands/Setlist.tsx
Normal file
174
src/components/islands/Setlist.tsx
Normal 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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
212
src/components/islands/SkeudCard.tsx
Normal file
212
src/components/islands/SkeudCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/islands/SkillCard.tsx
Normal file
66
src/components/islands/SkillCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/islands/SmoothScroll.tsx
Normal file
26
src/components/islands/SmoothScroll.tsx
Normal 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;
|
||||
}
|
||||
54
src/components/islands/SplitReveal.tsx
Normal file
54
src/components/islands/SplitReveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/islands/Waveform.tsx
Normal file
75
src/components/islands/Waveform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user