Commit 7fb570e4 by PLN (Algolia)

feat(parvagues): rebuild SPA landing page

New architecture:
- Hero: full-viewport with Syne typeface, subtle neon glow, tagline
- On Tour: vertical timeline grouped by year (2022-2026), 30 events
- Music: albums with revenue-ordered platform links (Bandcamp first),
  click-to-load streaming embeds (no auto-connect)
- Video: archive.org click-to-load cards (4 TOPLAP recordings)
- Booking: mailto-based contact form
- Footer: social icons + email

Design: editorial dark aesthetic, Syne font, restrained neon accents.
Zero third-party connections until user interaction.
parent dd1c8e50
import { FaSpotify, FaDeezer, FaYoutube, FaApple, FaBandcamp } from 'react-icons/fa';
import { SiTidal, SiDeezer } from 'react-icons/si';
const albumsData = [
{
id: '2024_opal',
title: 'Livecoding (Opal Festival 2024)',
image: '/images/parvagues/albums/2024_opal/cover.jpg',
links: [
{ platform: 'Spotify', url: 'https://open.spotify.com/album/1VKLZWeolFNfES2bWzYCWZ', icon: <FaSpotify /> },
{ platform: 'Bandcamp', url: 'https://parvagues.bandcamp.com/album/livecoding-opal-festival-2024', icon: <FaBandcamp /> },
{ platform: 'YouTube', url: 'https://www.youtube.com/playlist?list=OLAK5uy_l4MF3OCIXcdPMpsHGVX2Q9MiX6oU1zT6g', icon: <FaYoutube /> },
{ platform: 'Apple', url: 'https://music.apple.com/fr/album/livecoding-opal-festival-2024/1773790990', icon: <FaApple /> },
{ platform: 'Deezer', url: 'https://www.deezer.com/fr/album/632734951', icon: <SiDeezer /> },
]
},
{
id: '2023_connexion',
title: 'Connexion Etablie EP',
image: '/images/parvagues/albums/2023_connexion/cover.jpg',
links: [
{ platform: 'Spotify', url: 'https://open.spotify.com/album/4uzSN6Uv9IwcYeHdRtkUmM', icon: <FaSpotify /> },
{ platform: 'Bandcamp', url: 'https://parvagues.bandcamp.com/album/connexion-tablie', icon: <FaBandcamp /> },
{ platform: 'YouTube', url: 'https://www.youtube.com/watch?v=VODSdQKrzyw&list=OLAK5uy_nzlx3b7YJYzrbagXF5swhENsCg5vJkT_Q', icon: <FaYoutube /> },
{ platform: 'Apple', url: 'https://music.apple.com/fr/album/_/1711226283', icon: <FaApple /> },
{ platform: 'Deezer', url: 'https://www.deezer.com/fr/album/505854371', icon: <SiDeezer /> },
]
}
];
export default function AlbumCarousel() {
return (
<div className="w-full py-24 bg-gradient-to-b from-black via-purple-900/10 to-black">
<h2 className="text-4xl md:text-6xl font-black text-center mb-16 text-white tracking-tight">
LATEST RELEASES
</h2>
<div className="flex flex-wrap justify-center gap-16 px-4">
{albumsData.map((album) => (
<div key={album.id} className="group relative w-full max-w-md bg-gray-900 rounded-2xl overflow-hidden shadow-2xl transition-all hover:-translate-y-4">
<div className="aspect-square relative overflow-hidden">
<img
src={album.image}
alt={album.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 filter group-hover:brightness-50"
/>
{/* Overlay with links */}
<div className="absolute inset-0 flex items-center justify-center gap-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex-wrap p-4">
{album.links.map((link) => (
<a
key={link.platform}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="w-12 h-12 bg-white rounded-full flex items-center justify-center text-black hover:text-purple-600 hover:scale-110 transition-all shadow-lg"
title={link.platform}
>
<span className="text-2xl">{link.icon}</span>
</a>
))}
</div>
</div>
<div className="p-8 text-center bg-gray-900 border-t border-white/5">
<h3 className="text-2xl font-bold text-white mb-2">{album.title}</h3>
<p className="text-gray-400 text-sm uppercase tracking-widest">Listen Now</p>
</div>
</div>
))}
</div>
</div>
);
}
import { useState } from 'react';
import { FaEnvelope } from 'react-icons/fa';
const eventTypes = [
{ value: '', label: 'Type d\'événement' },
{ value: 'festival', label: 'Festival' },
{ value: 'private', label: 'Événement privé' },
{ value: 'corporate', label: 'Corporate' },
{ value: 'collab', label: 'Collaboration artistique' },
{ value: 'other', label: 'Autre' },
];
const budgetRanges = [
{ value: '', label: 'Budget estimé' },
{ value: 'volunteer', label: 'Bénévole / échange' },
{ value: 'small', label: '< 500 €' },
{ value: 'medium', label: '500 – 1 500 €' },
{ value: 'large', label: '1 500 – 5 000 €' },
{ value: 'custom', label: '> 5 000 € / sur mesure' },
];
const inputClass =
'w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/60 focus:border-[var(--neon-high)]/40 focus:outline-none focus:ring-1 focus:ring-[var(--neon-high)]/20 transition-all duration-200 appearance-none';
const selectClass =
'w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-sm text-[var(--text-muted)] focus:border-[var(--neon-high)]/40 focus:outline-none focus:ring-1 focus:ring-[var(--neon-high)]/20 transition-all duration-200 appearance-none cursor-pointer';
export default function BookingForm() {
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.target);
const subject = encodeURIComponent(`Booking: ${data.get('eventType') || 'Inquiry'}${data.get('venue') || 'TBD'}`);
const body = encodeURIComponent(
`Nom: ${data.get('name')}\nEmail: ${data.get('email')}\nType: ${data.get('eventType')}\nDate: ${data.get('date')}\nLieu: ${data.get('venue')}\nBudget: ${data.get('budget')}\n\n${data.get('message')}`
);
window.location.href = `mailto:parvagues@nech.pl?subject=${subject}&body=${body}`;
setSubmitted(true);
};
return (
<section id="booking" className="max-w-5xl mx-auto px-6 py-24 md:py-32">
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-[0.15em] uppercase">
Booking
</h2>
<div className="h-px bg-white/10 mt-4 mb-4" />
<p className="text-sm text-[var(--text-muted)] mb-12 max-w-lg">
Intéressé·e par un live? Remplis le formulaire ci-dessous
ou écris directement à{' '}
<a href="mailto:parvagues@nech.pl" className="text-[var(--neon-high)]/80 hover:text-[var(--neon-high)] transition-colors">
parvagues@nech.pl
</a>
</p>
{submitted ? (
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-12 text-center">
<p className="font-display font-semibold text-lg mb-2">Merci !</p>
<p className="text-sm text-[var(--text-muted)]">
Ton client mail devrait s&apos;ouvrir avec le formulaire pré-rempli.
</p>
<button
onClick={() => setSubmitted(false)}
className="mt-6 text-xs text-[var(--text-muted)] hover:text-white transition-colors tracking-wider underline underline-offset-4"
>
Envoyer un autre message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5 max-w-2xl">
<div className="grid sm:grid-cols-2 gap-5">
<input
name="name"
type="text"
placeholder="Nom"
required
className={inputClass}
/>
<input
name="email"
type="email"
placeholder="Email"
required
className={inputClass}
/>
</div>
<div className="grid sm:grid-cols-2 gap-5">
<select name="eventType" required className={selectClass}>
{eventTypes.map((t) => (
<option key={t.value} value={t.value} disabled={!t.value}>
{t.label}
</option>
))}
</select>
<input
name="date"
type="date"
className={`${inputClass} text-[var(--text-muted)]`}
/>
</div>
<input
name="venue"
type="text"
placeholder="Lieu / Ville"
className={inputClass}
/>
<select name="budget" className={selectClass}>
{budgetRanges.map((b) => (
<option key={b.value} value={b.value} disabled={!b.value}>
{b.label}
</option>
))}
</select>
<textarea
name="message"
placeholder="Décris ton projet, l'ambiance, tes attentes..."
rows={5}
className={`${inputClass} resize-none`}
/>
<button
type="submit"
className="flex items-center gap-3 px-8 py-3.5 bg-white text-[var(--surface)] font-display font-bold text-sm tracking-[0.15em] uppercase rounded-full hover:shadow-[0_0_30px_rgba(255,255,255,0.15)] transition-all duration-300"
>
<FaEnvelope className="w-4 h-4" />
Envoyer
</button>
</form>
)}
</section>
);
}
import { useState, useEffect } from 'react';
export default function Countdown({ targetDate, onPhaseChange }) {
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft());
function calculateTimeLeft() {
const difference = +new Date(targetDate) - +new Date();
let timeLeft = {};
if (difference > 0) {
timeLeft = {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60),
};
}
return timeLeft;
}
useEffect(() => {
const timer = setTimeout(() => {
setTimeLeft(calculateTimeLeft());
}, 1000);
return () => clearTimeout(timer);
});
const timerComponents = [];
Object.keys(timeLeft).forEach((interval) => {
if (!timeLeft[interval]) {
return;
}
timerComponents.push(
<span key={interval} className="mx-2">
<span className="text-4xl font-bold font-mono text-white">{timeLeft[interval]}</span>
<span className="text-sm text-gray-400 uppercase ml-1">{interval}</span>
</span>
);
});
return (
<div className="flex justify-center items-center p-6 bg-black/50 rounded-xl border border-purple-500/30">
{timerComponents.length ? timerComponents : <span className="text-2xl font-bold text-white">Event Started!</span>}
</div>
);
}
import Image from 'next/image';
export default function Hero() {
return (
<section className="relative h-screen flex items-center justify-center overflow-hidden">
{/* Background */}
<div className="absolute inset-0">
<Image
src="/images/parvagues/lives/2024/ccc_release_party/poster.png"
alt=""
fill
className="object-cover opacity-25 filter brightness-75"
priority
quality={60}
/>
<div className="absolute inset-0 bg-gradient-to-b from-[var(--surface)] via-[var(--surface)]/70 to-[var(--surface)]" />
</div>
{/* Content */}
<div className="relative z-10 text-center px-6 max-w-3xl">
<h1
className="font-display text-[clamp(4rem,15vw,10rem)] font-extrabold leading-[0.85] tracking-tight"
style={{
textShadow: '0 0 80px rgba(217,0,255,0.2), 0 0 160px rgba(217,0,255,0.08)',
}}
>
ParVagues
</h1>
<div className="h-px w-24 mx-auto bg-gradient-to-r from-transparent via-[var(--neon-high)]/40 to-transparent mt-8 mb-8" />
<p className="text-base md:text-lg text-[var(--text-muted)] italic leading-relaxed max-w-xl mx-auto">
ParVagues, c&apos;est des ondes qui naissent dans un océan binaire
pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center">
<a
href="#tour"
className="px-8 py-3.5 bg-white text-[var(--surface)] font-display font-bold text-sm tracking-[0.15em] uppercase rounded-full hover:shadow-[0_0_30px_rgba(255,255,255,0.15)] transition-all duration-300"
>
On Tour
</a>
<a
href="#music"
className="px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-[0.15em] uppercase rounded-full hover:bg-white/10 hover:border-white/50 transition-all duration-300"
>
Écouter
</a>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="w-px h-12 bg-gradient-to-b from-transparent to-white/30 animate-pulse" />
</div>
</section>
);
}
import { useState } from 'react';
import Masonry from 'react-masonry-css';
import styles from '@/styles/parvagues.module.css';
export default function ImageGallery({ images }) {
const [selectedImage, setSelectedImage] = useState(null);
const breakpointColumnsObj = {
default: 3,
1100: 3,
700: 2,
500: 1
};
if (!images || images.length === 0) return null;
return (
<>
<Masonry
breakpointCols={breakpointColumnsObj}
className={styles.galleryGrid}
columnClassName={styles.galleryGridColumn}
>
{images.map((image, index) => (
<div
key={index}
className={`${styles.galleryCard} mb-4 cursor-pointer overflow-hidden rounded-lg border border-transparent hover:border-purple-500/50`}
onClick={() => setSelectedImage(image)}
>
<img
src={image}
alt={`Gallery image ${index + 1}`}
className="w-full h-auto block"
loading="lazy"
/>
</div>
))}
</Masonry>
{selectedImage && (
<div
className={styles.modalOverlay}
onClick={() => setSelectedImage(null)}
>
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
<button
className={styles.modalCloseButton}
onClick={() => setSelectedImage(null)}
>
&times;
</button>
<img
src={selectedImage}
alt="Full size"
className="max-w-full max-h-[90vh] object-contain"
/>
</div>
</div>
)}
</>
);
}
import Head from 'next/head';
import Link from 'next/link';
import Image from 'next/image';
import { Syne } from 'next/font/google';
import { useState, useEffect } from 'react';
import { FaEnvelope, FaInstagram, FaYoutube, FaGithub } from 'react-icons/fa';
import { SiBluesky, SiMastodon } from 'react-icons/si';
const syne = Syne({
subsets: ['latin'],
variable: '--font-syne',
display: 'swap',
weight: ['400', '600', '700', '800'],
});
const socials = [
{ href: 'https://instagram.com/parvagues.mp3', icon: FaInstagram, label: 'Instagram' },
{ href: 'https://bsky.app/profile/nech.pl', icon: SiBluesky, label: 'Bluesky' },
{ href: 'https://github.com/parvagues', icon: FaGithub, label: 'GitHub' },
{ href: 'https://youtube.com/@parvagues', icon: FaYoutube, label: 'YouTube' },
{ href: 'https://chaos.social/@PixelNoir', icon: SiMastodon, label: 'Mastodon' },
];
export default function Layout({ children, title = 'ParVagues' }) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 50);
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div className={`${syne.variable} min-h-screen bg-[var(--surface)] text-[var(--text-primary)] selection:bg-[var(--neon-high)]/30 selection:text-white`}>
<Head>
<title>{title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="ParVagues — Livecoding de musique électronique. Ondes binaires, plages sonores." />
<meta property="og:title" content={title} />
<meta property="og:type" content="music.musician" />
<link rel="icon" href="/images/parvagues/logo.png" />
</Head>
{/* Header */}
<header
className={`fixed top-0 w-full z-50 transition-all duration-500 ${
scrolled
? 'bg-[var(--surface)]/95 backdrop-blur-md border-b border-white/[0.06]'
: ''
}`}
>
<div className="max-w-5xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/parvagues" className="flex items-center gap-3 group">
<Image
src="/images/parvagues/logo.png"
alt="ParVagues"
width={28}
height={28}
className="transition-all duration-300 group-hover:drop-shadow-[0_0_8px_rgba(217,0,255,0.5)]"
/>
<span
className="font-display font-bold text-xs tracking-[0.15em] uppercase transition-opacity duration-500"
style={{ opacity: scrolled ? 1 : 0 }}
>
ParVagues
</span>
</Link>
<nav className="hidden md:flex items-center gap-8 text-[11px] tracking-[0.2em] uppercase">
{['tour', 'music', 'video', 'booking'].map((id) => (
<a
key={id}
href={`#${id}`}
className="text-[var(--text-muted)] hover:text-white transition-colors duration-300"
>
{id}
</a>
))}
</nav>
<a
href="#booking"
className="flex items-center gap-2 px-4 py-2 text-[11px] tracking-[0.15em] uppercase border border-white/20 rounded-full hover:bg-white hover:text-[var(--surface)] transition-all duration-300"
>
<FaEnvelope className="w-3 h-3" />
<span className="hidden sm:inline">Book</span>
</a>
</div>
</header>
<main>{children}</main>
{/* Footer */}
<footer className="border-t border-white/[0.06] py-16">
<div className="max-w-5xl mx-auto px-6">
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-6">
{socials.map(({ href, icon: Icon, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--text-muted)] hover:text-white transition-colors duration-300"
aria-label={label}
>
<Icon className="w-5 h-5" />
</a>
))}
</div>
<div className="text-center">
<p className="text-[var(--text-muted)] text-xs tracking-wider">
© {new Date().getFullYear()} ParVagues
</p>
<a
href="mailto:parvagues@nech.pl"
className="text-[var(--text-muted)] hover:text-[var(--neon-high)] text-xs tracking-wider transition-colors"
>
parvagues@nech.pl
</a>
</div>
</div>
</div>
</footer>
</div>
);
}
import Link from 'next/link';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { FaSoundcloud, FaSpotify, FaYoutube, FaTwitch } from 'react-icons/fa';
// Generate consistent gradient based on string hash
function generateGradient(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue1 = Math.abs(hash % 360);
const hue2 = (hue1 + 60) % 360;
return `linear-gradient(135deg, hsl(${hue1}, 70%, 25%) 0%, hsl(${hue2}, 60%, 15%) 100%)`;
}
// Platform badge component
function PlatformBadge({ url, icon: Icon, label }) {
if (!url) return null;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2 py-1 bg-white/10 hover:bg-white/20 rounded-full text-xs transition-all"
onClick={(e) => e.stopPropagation()}
>
<Icon className="w-3 h-3" />
<span className="hidden sm:inline">{label}</span>
</a>
);
}
export default function LiveList({ lives }) {
const sortedLives = [...lives].sort((a, b) => new Date(b.date) - new Date(a.date));
return (
<div className="w-full max-w-7xl mx-auto py-24 px-4">
<div className="flex items-end justify-between mb-16 border-b border-white/10 pb-4">
<h2 className="text-5xl md:text-7xl font-black text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-600">
LIVE SETS
</h2>
<span className="text-purple-400 font-mono text-sm hidden md:block">
// {sortedLives.length} performances
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{sortedLives.map((live) => {
const date = new Date(live.date);
const isFuture = date > new Date();
const isValid = !isNaN(date.getTime());
const gradient = generateGradient(live.slug || live.title);
return (
<Link href={`/parvagues/live/${live.slug}`} key={live.slug}>
<div className="group relative overflow-hidden rounded-2xl border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:shadow-[0_0_30px_rgba(168,85,247,0.2)]">
{/* Background gradient */}
<div
className="absolute inset-0 opacity-40 group-hover:opacity-60 transition-opacity"
style={{ background: gradient }}
/>
{/* Noise texture overlay */}
<div className="absolute inset-0 opacity-5 mix-blend-overlay" style={{
backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 400 400\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.9\' numOctaves=\'4\' /%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\' /%3E%3C/svg%3E")'
}} />
{/* Content */}
<div className="relative p-6 flex flex-col md:flex-row items-start md:items-center gap-4">
{/* Date badge */}
<div className="flex-shrink-0">
<div className={`px-4 py-2 rounded-xl font-mono font-bold text-sm ${isFuture
? 'bg-purple-500/30 text-purple-200 border border-purple-400/50'
: 'bg-white/10 text-gray-300 border border-white/20'
}`}>
{isValid ? format(date, 'dd MMM yyyy', { locale: fr }) : 'TBD'}
</div>
{isFuture && (
<div className="mt-2 px-2 py-1 bg-purple-500/20 text-purple-300 text-xs font-bold uppercase tracking-widest rounded text-center">
Upcoming
</div>
)}
</div>
{/* Event info */}
<div className="flex-grow min-w-0">
<h3 className="text-xl md:text-2xl font-bold text-white group-hover:text-purple-300 transition-colors mb-1 truncate">
{live.title}
</h3>
<p className="text-gray-400 text-sm md:text-base group-hover:text-gray-300 transition-colors">
📍 {live.location}
</p>
{/* Platform badges */}
{(live.audio || live.video || live.ctaURL) && (
<div className="flex flex-wrap gap-2 mt-3">
{live.audio && live.audio.includes('soundcloud') && (
<PlatformBadge url={live.audio} icon={FaSoundcloud} label="SoundCloud" />
)}
{live.audio && live.audio.includes('spotify') && (
<PlatformBadge url={live.audio} icon={FaSpotify} label="Spotify" />
)}
{live.video && live.video.includes('youtube') && (
<PlatformBadge url={live.video} icon={FaYoutube} label="YouTube" />
)}
{live.video && live.video.includes('twitch') && (
<PlatformBadge url={live.video} icon={FaTwitch} label="Twitch" />
)}
</div>
)}
</div>
{/* Arrow CTA */}
<div className="flex-shrink-0">
<div className="w-12 h-12 rounded-full border-2 border-white/30 flex items-center justify-center group-hover:bg-purple-500 group-hover:border-purple-500 transition-all transform group-hover:rotate-45 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</div>
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
import { useState } from 'react';
import Image from 'next/image';
import {
FaBandcamp, FaSpotify, FaYoutube, FaApple, FaSoundcloud, FaInstagram,
} from 'react-icons/fa';
// react-icons doesn't ship a Deezer icon in this version
function DeezerIcon({ className }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.81 4.16v3.03H24V4.16h-5.19zM6.27 8.38v3.027h5.19V8.38H6.27zm12.54 0v3.027H24V8.38h-5.19zM6.27 12.594v3.027h5.19v-3.027H6.27zm6.27 0v3.027h5.19v-3.027h-5.19zm6.27 0v3.027H24v-3.027h-5.19zM0 16.81v3.029h5.19v-3.03H0zm6.27 0v3.029h5.19v-3.03H6.27zm6.27 0v3.029h5.19v-3.03h-5.19zm6.27 0v3.029H24v-3.03h-5.19z"/>
</svg>
);
}
const albums = [
{
id: '2024_opal',
title: 'Livecoding (Opal Festival 2024)',
image: '/images/parvagues/albums/2024_opal/cover.jpg',
links: [
{ platform: 'Bandcamp', url: 'https://parvagues.bandcamp.com/album/livecoding-opal-festival-2024' },
{ platform: 'Deezer', url: 'https://www.deezer.com/fr/album/632734951' },
{ platform: 'Apple Music', url: 'https://music.apple.com/fr/album/livecoding-opal-festival-2024/1773790990' },
{ platform: 'Spotify', url: 'https://open.spotify.com/album/1VKLZWeolFNfES2bWzYCWZ' },
{ platform: 'YouTube', url: 'https://www.youtube.com/playlist?list=OLAK5uy_l4MF3OCIXcdPMpsHGVX2Q9MiX6oU1zT6g' },
],
},
{
id: '2023_connexion',
title: 'Connexion Établie EP',
image: '/images/parvagues/albums/2023_connexion/cover.jpg',
links: [
{ platform: 'Bandcamp', url: 'https://parvagues.bandcamp.com/album/connexion-tablie' },
{ platform: 'Deezer', url: 'https://www.deezer.com/fr/album/505854371' },
{ platform: 'Apple Music', url: 'https://music.apple.com/fr/album/_/1711226283' },
{ platform: 'Spotify', url: 'https://open.spotify.com/album/4uzSN6Uv9IwcYeHdRtkUmM' },
{ platform: 'YouTube', url: 'https://www.youtube.com/watch?v=VODSdQKrzyw&list=OLAK5uy_nzlx3b7YJYzrbagXF5swhENsCg5vJkT_Q' },
],
},
];
const streamingPlatforms = [
{
id: 'soundcloud',
label: 'SoundCloud',
icon: FaSoundcloud,
color: '#ff5500',
embedUrl: 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/users/1084818893&color=%23a700d1&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true',
embedHeight: 450,
profileUrl: 'https://soundcloud.com/parvagues',
},
{
id: 'bandcamp',
label: 'Bandcamp',
icon: FaBandcamp,
color: '#1da0c3',
embedUrl: 'https://bandcamp.com/EmbeddedPlayer/album=3869867806/size=large/bgcol=333333/linkcol=a700d1/tracklist=false/transparent=true/',
embedHeight: 450,
profileUrl: 'https://parvagues.bandcamp.com/',
},
{
id: 'spotify',
label: 'Spotify',
icon: FaSpotify,
color: '#1db954',
embedUrl: 'https://open.spotify.com/embed/artist/0kznTQnx5QRhMwktmZboX4?utm_source=generator&theme=0',
embedHeight: 450,
profileUrl: 'https://open.spotify.com/artist/0kznTQnx5QRhMwktmZboX4',
},
{
id: 'youtube',
label: 'YouTube',
icon: FaYoutube,
color: '#ff0000',
embedUrl: 'https://www.youtube.com/embed?listType=user_uploads&list=@parvagues',
embedHeight: 400,
profileUrl: 'https://www.youtube.com/@parvagues/videos',
isVideo: true,
},
{
id: 'deezer',
label: 'Deezer',
icon: DeezerIcon,
color: '#a238ff',
embedUrl: 'https://widget.deezer.com/widget/dark/artist/103670512/top_tracks',
embedHeight: 450,
profileUrl: 'https://www.deezer.com/fr/artist/103670512',
},
{
id: 'instagram',
label: 'Instagram',
icon: FaInstagram,
color: '#e4405f',
profileUrl: 'https://www.instagram.com/parvagues.mp3/',
isPrivacy: true,
},
];
function AlbumCard({ album }) {
return (
<div className="group">
<div className="aspect-square relative rounded-xl overflow-hidden mb-5 bg-[var(--surface-raised)]">
<Image
src={album.image}
alt={album.title}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
<h4 className="font-display font-semibold text-base mb-3">{album.title}</h4>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{album.links.map((link) => (
<a
key={link.platform}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--text-muted)] hover:text-[var(--neon-high)] transition-colors duration-200 tracking-wide"
>
{link.platform}
</a>
))}
</div>
</div>
);
}
function StreamingEmbed({ platform, onClose }) {
if (platform.isPrivacy) {
return (
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-12 text-center">
<FaInstagram className="w-10 h-10 text-[var(--text-muted)] mx-auto mb-4" />
<p className="text-sm text-[var(--text-muted)] mb-6 max-w-sm mx-auto">
Le contenu Instagram se connecte aux serveurs de Meta et peut suivre votre activité.
</p>
<a
href={platform.profileUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 text-sm font-display font-semibold tracking-wider rounded-full border border-white/20 hover:bg-white hover:text-[var(--surface)] transition-all duration-300"
>
Voir sur Instagram
</a>
</div>
);
}
return (
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl overflow-hidden">
<div style={{ height: platform.isVideo ? undefined : platform.embedHeight }}>
<iframe
src={platform.embedUrl}
width="100%"
height={platform.isVideo ? undefined : platform.embedHeight}
className={platform.isVideo ? 'aspect-video w-full' : ''}
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ borderRadius: '12px' }}
/>
</div>
<div className="px-6 py-4 flex items-center justify-between border-t border-white/[0.06]">
<a
href={platform.profileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--text-muted)] hover:text-white transition-colors tracking-wider"
>
Voir le profil complet
</a>
<button
onClick={onClose}
className="text-xs text-[var(--text-muted)] hover:text-white transition-colors tracking-wider"
>
Fermer
</button>
</div>
</div>
);
}
export default function MusicSection() {
const [activeEmbed, setActiveEmbed] = useState(null);
const toggle = (id) => setActiveEmbed(activeEmbed === id ? null : id);
const activePlatform = streamingPlatforms.find((p) => p.id === activeEmbed);
return (
<section id="music" className="max-w-5xl mx-auto px-6 py-24 md:py-32">
{/* Releases */}
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-[0.15em] uppercase">
Releases
</h2>
<div className="h-px bg-white/10 mt-4 mb-12" />
<div className="grid md:grid-cols-2 gap-10 md:gap-12 mb-28">
{albums.map((album) => (
<AlbumCard key={album.id} album={album} />
))}
</div>
{/* Streaming */}
<h3 className="font-display text-xl md:text-2xl font-bold tracking-[0.15em] uppercase">
Streaming
</h3>
<div className="h-px bg-white/10 mt-4 mb-8" />
<div className="flex flex-wrap gap-3 mb-8">
{streamingPlatforms.map(({ id, label, icon: Icon, color }) => (
<button
key={id}
onClick={() => toggle(id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-xs font-display font-semibold tracking-wider transition-all duration-300 ${
activeEmbed === id
? 'text-white shadow-lg scale-105'
: 'bg-white/[0.04] text-[var(--text-muted)] hover:bg-white/[0.08] hover:text-white'
}`}
style={
activeEmbed === id
? { backgroundColor: color, boxShadow: `0 0 20px ${color}30` }
: {}
}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{activePlatform && (
<StreamingEmbed
platform={activePlatform}
onClose={() => setActiveEmbed(null)}
/>
)}
</section>
);
}
import { FaInstagram, FaTwitter, FaGithub } from 'react-icons/fa';
import { SiBluesky, SiMastodon } from 'react-icons/si';
export default function SocialCTA() {
return (
<div className="flex flex-wrap justify-center gap-6 py-12">
<a
href="https://instagram.com/parvagues"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-gradient-to-br from-purple-600 to-pink-600 px-8 py-4 rounded-full text-white font-bold text-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:scale-105 shadow-lg hover:shadow-purple-500/50"
>
<FaInstagram className="text-2xl" />
<span>Instagram</span>
</a>
<a
href="https://bsky.app/profile/nech.pl"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-gray-800 hover:bg-gray-700 px-8 py-4 rounded-full text-white font-bold text-lg transition-all transform hover:scale-105 shadow-lg border border-white/10 hover:border-white/30"
>
<SiBluesky className="text-2xl" />
<span>Bluesky</span>
</a>
<a
href="https://github.com/parvagues"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-black hover:bg-gray-900 px-8 py-4 rounded-full text-white font-bold text-lg transition-all transform hover:scale-105 shadow-lg border border-white/30 hover:border-white"
>
<FaGithub className="text-2xl" />
<span>GitHub</span>
</a>
</div>
);
}
import Link from 'next/link';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
function MediaDot({ live }) {
const has = live.audio || live.video || live.archive;
if (!has) return null;
return (
<span
className="w-1.5 h-1.5 rounded-full bg-[var(--neon-high)]/60 flex-shrink-0"
title="Enregistrement disponible"
/>
);
}
export default function TourTimeline({ lives }) {
const now = new Date();
// Group by year, sort desc
const grouped = {};
lives.forEach((live) => {
const d = new Date(live.date + 'T00:00:00');
const year = d.getFullYear();
if (!grouped[year]) grouped[year] = [];
grouped[year].push({ ...live, _date: d });
});
const years = Object.keys(grouped)
.sort((a, b) => b - a);
// Within each year, sort by date desc
years.forEach((y) => {
grouped[y].sort((a, b) => b._date - a._date);
});
return (
<section id="tour" className="max-w-5xl mx-auto px-6 py-24 md:py-32">
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-[0.15em] uppercase">
On Tour
</h2>
<div className="h-px bg-white/10 mt-4 mb-16" />
{years.map((year) => (
<div key={year} className="mb-20 last:mb-0">
{/* Year watermark */}
<h3 className="font-display text-[clamp(4rem,12vw,8rem)] font-extrabold text-white/[0.04] leading-none -mb-6 md:-mb-8 select-none pointer-events-none">
{year}
</h3>
{/* Events */}
<div className="relative">
{grouped[year].map((live) => {
const isFuture = live._date > now;
const city = live.location?.includes(',')
? live.location.split(',')[0].trim()
: live.location;
return (
<Link
key={live.slug}
href={`/parvagues/live/${live.slug}`}
className={`group flex items-center gap-3 md:gap-6 py-3 px-3 md:px-4 -mx-3 md:-mx-4 rounded-lg transition-colors duration-200 hover:bg-white/[0.03] ${
isFuture ? 'border-l-2 border-[var(--neon-high)]/50 pl-4 md:pl-5' : ''
}`}
>
{/* Date */}
<span className="text-[11px] font-mono text-[var(--text-muted)] w-14 flex-shrink-0 uppercase tracking-wide">
{format(live._date, 'dd MMM', { locale: fr })}
</span>
{/* Title */}
<span className="font-display font-semibold text-sm md:text-base flex-grow min-w-0 truncate group-hover:text-white transition-colors duration-200">
{live.title}
</span>
{/* City */}
<span className="text-[11px] text-[var(--text-muted)] hidden sm:block flex-shrink-0 tracking-wide">
{city}
</span>
{/* Media indicator */}
<MediaDot live={live} />
{/* Arrow */}
<svg
className="w-4 h-4 text-[var(--text-muted)] group-hover:text-white group-hover:translate-x-1 transition-all duration-200 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
</svg>
</Link>
);
})}
</div>
</div>
))}
</section>
);
}
import { useState } from 'react';
import { FaPlay } from 'react-icons/fa';
const videos = [
{
id: 'toplap-fromscratch-dec2025-parvagues',
title: 'From Scratch — Jungle 🐅',
subtitle: 'TOPLAP Stream · Orléans',
date: 'Déc 2025',
},
{
id: 'toplap-solstice-dec2024-parvagues-',
title: 'Solstice Stream',
subtitle: 'TOPLAP · Les Carroz (Alps)',
date: 'Déc 2024',
},
{
id: 'toplap20-parvagues---z0rg',
title: '20 Years (w/ z0rg)',
subtitle: 'TOPLAP · Grand Paris',
date: 'Fév 2024',
},
{
id: 'latesolstice2023-parvagues',
title: 'Solstice Stream',
subtitle: 'TOPLAP · Grand Paris',
date: 'Déc 2023',
},
];
function VideoCard({ video }) {
const [loaded, setLoaded] = useState(false);
if (loaded) {
return (
<div>
<div className="aspect-video rounded-xl overflow-hidden bg-black">
<iframe
src={`https://archive.org/embed/${video.id}`}
width="100%"
height="100%"
allowFullScreen
className="w-full h-full"
/>
</div>
<div className="mt-3 flex items-start justify-between">
<div>
<h4 className="font-display font-semibold text-sm">{video.title}</h4>
<p className="text-[11px] text-[var(--text-muted)] mt-0.5">{video.subtitle} · {video.date}</p>
</div>
<button
onClick={() => setLoaded(false)}
className="text-[11px] text-[var(--text-muted)] hover:text-white transition-colors tracking-wider mt-1"
>
Fermer
</button>
</div>
</div>
);
}
return (
<div>
<button
onClick={() => setLoaded(true)}
className="aspect-video w-full bg-white/[0.03] border border-white/[0.06] rounded-xl flex flex-col items-center justify-center gap-3 cursor-pointer group transition-all duration-300 hover:bg-white/[0.05] hover:border-white/[0.12]"
>
<div className="w-14 h-14 rounded-full bg-white/[0.06] flex items-center justify-center group-hover:bg-[var(--neon-high)]/15 group-hover:scale-110 transition-all duration-300">
<FaPlay className="w-4 h-4 text-white/60 group-hover:text-white ml-0.5 transition-colors" />
</div>
<span className="text-[11px] text-[var(--text-muted)] tracking-wider group-hover:text-white/60 transition-colors">
archive.org
</span>
</button>
<div className="mt-3">
<h4 className="font-display font-semibold text-sm">{video.title}</h4>
<p className="text-[11px] text-[var(--text-muted)] mt-0.5">{video.subtitle} · {video.date}</p>
</div>
</div>
);
}
export default function VideoSection() {
return (
<section id="video" className="max-w-5xl mx-auto px-6 py-24 md:py-32">
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-[0.15em] uppercase">
Video
</h2>
<div className="h-px bg-white/10 mt-4 mb-12" />
<div className="grid sm:grid-cols-2 gap-8 md:gap-10">
{videos.map((video) => (
<VideoCard key={video.id} video={video} />
))}
</div>
</section>
);
}
/* Add the ParVagues color variables globally */ @tailwind base;
@tailwind components;
@tailwind utilities;
/* ParVagues palette */
:root { :root {
--neon-down: #8900b3; --neon-down: #8900b3;
--neon-low: #a700d1; --neon-low: #a700d1;
...@@ -6,7 +10,20 @@ ...@@ -6,7 +10,20 @@
--coral: #ff3d7b; --coral: #ff3d7b;
--biomod: #5bc091; --biomod: #5bc091;
--cigarette: #ff8c00; --cigarette: #ff8c00;
} --surface: #0a0a0a;
--surface-raised: #111111;
--text-primary: #e5e5e5;
--text-muted: #737373;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 4.5rem;
}
.font-display {
font-family: var(--font-syne), system-ui, sans-serif;
}
/* Shine animation for album covers */ /* Shine animation for album covers */
...@@ -14,6 +31,7 @@ ...@@ -14,6 +31,7 @@
0% { 0% {
transform: translateX(-100%); transform: translateX(-100%);
} }
100% { 100% {
transform: translateX(100%); transform: translateX(100%);
} }
...@@ -25,4 +43,4 @@ ...@@ -25,4 +43,4 @@
:global(.animate-shine) { :global(.animate-shine) {
animation: shine 1.5s ease-in-out; animation: shine 1.5s ease-in-out;
} }
\ No newline at end of file
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
"**/*.tsx" "**/*.tsx"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules",
"playwright.config.ts"
] ]
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment