Commit 40d209c6 by PLN (Algolia)

fix(parvagues): visual polish and Tailwind v4 migration

- Fix globals.css: @import "tailwindcss" + @source (v4 syntax)
- Fix hero: reduce poster opacity to 10%, use inline clamp for title
- Remove oversized arrows from tour timeline, use inline styles
- Add custom DeezerIcon SVG (react-icons has no Deezer icon)
- Replace em dashes with appropriate punctuation throughout
- Add playwright dev dependency + screenshots script
parent 7fb570e4
...@@ -31,7 +31,7 @@ export default function BookingForm() { ...@@ -31,7 +31,7 @@ export default function BookingForm() {
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target);
const subject = encodeURIComponent(`Booking: ${data.get('eventType') || 'Inquiry'} ${data.get('venue') || 'TBD'}`); const subject = encodeURIComponent(`Booking: ${data.get('eventType') || 'Inquiry'} - ${data.get('venue') || 'TBD'}`);
const body = encodeURIComponent( 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')}` `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')}`
); );
......
...@@ -9,41 +9,51 @@ export default function Hero() { ...@@ -9,41 +9,51 @@ export default function Hero() {
src="/images/parvagues/lives/2024/ccc_release_party/poster.png" src="/images/parvagues/lives/2024/ccc_release_party/poster.png"
alt="" alt=""
fill fill
className="object-cover opacity-25 filter brightness-75" className="object-cover opacity-10"
priority priority
quality={60}
/> />
<div className="absolute inset-0 bg-gradient-to-b from-[var(--surface)] via-[var(--surface)]/70 to-[var(--surface)]" /> {/* Heavy overlay to kill poster text bleed */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(to bottom, var(--surface) 0%, rgba(10,10,10,0.85) 40%, rgba(10,10,10,0.85) 60%, var(--surface) 100%)',
}}
/>
</div> </div>
{/* Content */} {/* Content */}
<div className="relative z-10 text-center px-6 max-w-3xl"> <div className="relative z-10 text-center px-6 max-w-4xl">
<h1 <h1
className="font-display text-[clamp(4rem,15vw,10rem)] font-extrabold leading-[0.85] tracking-tight" className="font-display font-extrabold leading-none tracking-tight"
style={{ style={{
fontSize: 'clamp(5rem, 15vw, 12rem)',
textShadow: '0 0 80px rgba(217,0,255,0.2), 0 0 160px rgba(217,0,255,0.08)', textShadow: '0 0 80px rgba(217,0,255,0.2), 0 0 160px rgba(217,0,255,0.08)',
}} }}
> >
ParVagues ParVagues
</h1> </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" /> <div
className="mx-auto mt-8 mb-8"
style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, transparent, rgba(217,0,255,0.4), transparent)' }}
/>
<p className="text-base md:text-lg text-[var(--text-muted)] italic leading-relaxed max-w-xl mx-auto"> <p className="text-base md:text-lg italic leading-relaxed max-w-xl mx-auto" style={{ color: 'var(--text-muted)' }}>
ParVagues, c&apos;est des ondes qui naissent dans un océan binaire ParVagues, c&apos;est des ondes qui naissent dans un océan binaire
pour parfois s&apos;échouer sur vos plages sonores. pour parfois s&apos;échouer sur vos plages sonores.
</p> </p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center"> <div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center items-center">
<a <a
href="#tour" 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" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300 hover:shadow-lg"
style={{ color: 'var(--surface)' }}
> >
On Tour On Tour
</a> </a>
<a <a
href="#music" 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" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 hover:border-white/50 transition-all duration-300"
> >
Écouter Écouter
</a> </a>
...@@ -52,7 +62,7 @@ export default function Hero() { ...@@ -52,7 +62,7 @@ export default function Hero() {
{/* Scroll indicator */} {/* Scroll indicator */}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10"> <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 className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div> </div>
</section> </section>
); );
......
...@@ -36,7 +36,7 @@ export default function Layout({ children, title = 'ParVagues' }) { ...@@ -36,7 +36,7 @@ export default function Layout({ children, title = 'ParVagues' }) {
<Head> <Head>
<title>{title}</title> <title>{title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="ParVagues Livecoding de musique électronique. Ondes binaires, plages sonores." /> <meta name="description" content="ParVagues - Livecoding de musique électronique. Ondes binaires, plages sonores." />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:type" content="music.musician" /> <meta property="og:type" content="music.musician" />
<link rel="icon" href="/images/parvagues/logo.png" /> <link rel="icon" href="/images/parvagues/logo.png" />
......
...@@ -100,7 +100,10 @@ const streamingPlatforms = [ ...@@ -100,7 +100,10 @@ const streamingPlatforms = [
function AlbumCard({ album }) { function AlbumCard({ album }) {
return ( return (
<div className="group"> <div className="group">
<div className="aspect-square relative rounded-xl overflow-hidden mb-5 bg-[var(--surface-raised)]"> <div
className="relative rounded-xl overflow-hidden mb-5"
style={{ aspectRatio: '1', backgroundColor: 'var(--surface-raised)' }}
>
<Image <Image
src={album.image} src={album.image}
alt={album.title} alt={album.title}
......
...@@ -2,17 +2,6 @@ import Link from 'next/link'; ...@@ -2,17 +2,6 @@ import Link from 'next/link';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { fr } from 'date-fns/locale'; 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 }) { export default function TourTimeline({ lives }) {
const now = new Date(); const now = new Date();
...@@ -25,32 +14,34 @@ export default function TourTimeline({ lives }) { ...@@ -25,32 +14,34 @@ export default function TourTimeline({ lives }) {
grouped[year].push({ ...live, _date: d }); grouped[year].push({ ...live, _date: d });
}); });
const years = Object.keys(grouped) const years = Object.keys(grouped).sort((a, b) => b - a);
.sort((a, b) => b - a);
// Within each year, sort by date desc
years.forEach((y) => { years.forEach((y) => {
grouped[y].sort((a, b) => b._date - a._date); grouped[y].sort((a, b) => b._date - a._date);
}); });
return ( return (
<section id="tour" className="max-w-5xl mx-auto px-6 py-24 md:py-32"> <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"> <h2 className="font-display text-2xl md:text-3xl font-bold tracking-widest uppercase">
On Tour On Tour
</h2> </h2>
<div className="h-px bg-white/10 mt-4 mb-16" /> <div style={{ height: '1px', background: 'rgba(255,255,255,0.1)', marginTop: '1rem', marginBottom: '3rem' }} />
{years.map((year) => ( {years.map((year) => (
<div key={year} className="mb-20 last:mb-0"> <div key={year} style={{ marginBottom: '3rem' }}>
{/* Year watermark */} {/* Year */}
<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"> <h3
className="font-display font-extrabold select-none pointer-events-none"
style={{ fontSize: 'clamp(3rem, 10vw, 6rem)', color: 'rgba(255,255,255,0.04)', lineHeight: 1, marginBottom: '-0.5rem' }}
>
{year} {year}
</h3> </h3>
{/* Events */} {/* Events */}
<div className="relative"> <div>
{grouped[year].map((live) => { {grouped[year].map((live) => {
const isFuture = live._date > now; const isFuture = live._date > now;
const hasMedia = live.audio || live.video || live.archive;
const city = live.location?.includes(',') const city = live.location?.includes(',')
? live.location.split(',')[0].trim() ? live.location.split(',')[0].trim()
: live.location; : live.location;
...@@ -59,38 +50,46 @@ export default function TourTimeline({ lives }) { ...@@ -59,38 +50,46 @@ export default function TourTimeline({ lives }) {
<Link <Link
key={live.slug} key={live.slug}
href={`/parvagues/live/${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] ${ className="group flex items-center gap-4 transition-colors duration-200 hover:bg-white/5"
isFuture ? 'border-l-2 border-[var(--neon-high)]/50 pl-4 md:pl-5' : '' style={{
}`} padding: '0.5rem 0.75rem',
margin: '0 -0.75rem',
borderRadius: '0.5rem',
borderLeft: isFuture ? '2px solid rgba(217,0,255,0.5)' : '2px solid transparent',
}}
> >
{/* Date */} {/* Date */}
<span className="text-[11px] font-mono text-[var(--text-muted)] w-14 flex-shrink-0 uppercase tracking-wide"> <span
className="font-mono flex-shrink-0"
style={{ fontSize: '11px', color: 'var(--text-muted)', width: '3.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
{format(live._date, 'dd MMM', { locale: fr })} {format(live._date, 'dd MMM', { locale: fr })}
</span> </span>
{/* Title */} {/* 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"> <span
className="font-display font-semibold flex-grow min-w-0 truncate group-hover:text-white transition-colors duration-200"
style={{ fontSize: '0.875rem' }}
>
{live.title} {live.title}
</span> </span>
{/* City */} {/* City - hidden on mobile */}
<span className="text-[11px] text-[var(--text-muted)] hidden sm:block flex-shrink-0 tracking-wide"> <span
className="hidden sm:block flex-shrink-0"
style={{ fontSize: '11px', color: 'var(--text-muted)', letterSpacing: '0.05em' }}
>
{city} {city}
</span> </span>
{/* Media indicator */} {/* Media dot */}
<MediaDot live={live} /> {hasMedia && (
<span
{/* Arrow */} className="flex-shrink-0 rounded-full"
<svg style={{ width: '6px', height: '6px', backgroundColor: 'rgba(217,0,255,0.5)' }}
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" title="Enregistrement disponible"
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> </Link>
); );
})} })}
......
...@@ -4,7 +4,7 @@ import { FaPlay } from 'react-icons/fa'; ...@@ -4,7 +4,7 @@ import { FaPlay } from 'react-icons/fa';
const videos = [ const videos = [
{ {
id: 'toplap-fromscratch-dec2025-parvagues', id: 'toplap-fromscratch-dec2025-parvagues',
title: 'From Scratch Jungle 🐅', title: 'From Scratch: Jungle 🐅',
subtitle: 'TOPLAP Stream · Orléans', subtitle: 'TOPLAP Stream · Orléans',
date: 'Déc 2025', date: 'Déc 2025',
}, },
......
...@@ -25,10 +25,10 @@ ...@@ -25,10 +25,10 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"d3-zoom": "^3.0.0", "d3-zoom": "^3.0.0",
"date-fns": "^3.3.1", "date-fns": "^3.6.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29", "hydra-synth": "^1.3.29",
"marked": "^15.0.11", "marked": "^15.0.12",
"minisearch": "^7.1.2", "minisearch": "^7.1.2",
"next": "^15.3.0", "next": "^15.3.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-instantsearch": "^7.15.7", "react-instantsearch": "^7.15.7",
"react-instantsearch-dom": "^6.40.4", "react-instantsearch-dom": "^6.40.4",
"react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16", "react-masonry-css": "^1.0.16",
"react-player": "^2.14.1", "react-player": "^2.14.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
...@@ -50,6 +51,7 @@ ...@@ -50,6 +51,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
...@@ -57,10 +59,14 @@ ...@@ -57,10 +59,14 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "24.4.0", "@types/node": "24.4.0",
"@types/react": "^18.2.61", "@types/react": "^18.2.61",
"autoprefixer": "^10.4.22",
"jest": "^30.1.3", "jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.2", "jest-environment-jsdom": "^30.1.2",
"jest-pnp-resolver": "^1.2.3", "jest-pnp-resolver": "^1.2.3",
"next-router-mock": "^1.0.2", "next-router-mock": "^1.0.2",
"playwright": "^1.58.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vercel": "^39" "vercel": "^39"
}, },
......
...@@ -8,7 +8,7 @@ import BookingForm from '@/components/parvagues/BookingForm'; ...@@ -8,7 +8,7 @@ import BookingForm from '@/components/parvagues/BookingForm';
export default function ParVagues({ lives }) { export default function ParVagues({ lives }) {
return ( return (
<Layout title="ParVagues Musique Algorithmique"> <Layout title="ParVagues - Musique Algorithmique">
<Hero /> <Hero />
<TourTimeline lives={lives} /> <TourTimeline lives={lives} />
<MusicSection /> <MusicSection />
......
/**
* Take screenshots of any page at desktop + mobile viewports.
*
* Usage:
* yarn node scripts/screenshots.js [url] [output-dir]
* (must use `yarn node` for PnP resolution)
*
* Defaults:
* url: http://localhost:3000/parvagues
* output-dir: /tmp/screenshots
*
* Scrolls through the page and captures each viewport-height chunk.
* Also captures anchored sections (#tour, #music, #video, #booking).
*/
// Use @playwright/test's bundled browser launcher
const { chromium } = require('playwright-core');
// If playwright-core isn't available, fallback:
// yarn add -D playwright-core
const path = require('path');
const fs = require('fs');
const BASE_URL = process.argv[2] || 'http://localhost:3000/parvagues';
const OUT_DIR = process.argv[3] || '/tmp/screenshots';
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
];
const SECTIONS = ['tour', 'music', 'video', 'booking'];
async function captureViewport({ name, width, height }) {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width, height } });
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
// Full-page screenshot
await page.screenshot({ path: path.join(OUT_DIR, `${name}-full.png`), fullPage: true });
// Hero (above the fold)
await page.screenshot({ path: path.join(OUT_DIR, `${name}-hero.png`) });
// Scroll-based captures (each viewport height)
const totalHeight = await page.evaluate(() => document.body.scrollHeight);
const chunks = Math.ceil(totalHeight / height);
for (let i = 1; i < Math.min(chunks, 8); i++) {
await page.evaluate((y) => window.scrollTo(0, y), i * height);
await new Promise((r) => setTimeout(r, 400));
await page.screenshot({ path: path.join(OUT_DIR, `${name}-scroll-${i}.png`) });
}
// Section-anchored captures
for (const id of SECTIONS) {
const found = await page.evaluate((sectionId) => {
const el = document.querySelector(`#${sectionId}`);
if (el) { el.scrollIntoView({ behavior: 'instant' }); return true; }
return false;
}, id);
if (found) {
await new Promise((r) => setTimeout(r, 400));
await page.screenshot({ path: path.join(OUT_DIR, `${name}-${id}.png`) });
}
}
await browser.close();
console.log(` ✓ ${name} (${width}×${height}): ${chunks} scroll chunks + ${SECTIONS.length} sections`);
}
(async () => {
fs.mkdirSync(OUT_DIR, { recursive: true });
console.log(`Capturing ${BASE_URL}${OUT_DIR}/\n`);
for (const vp of VIEWPORTS) {
await captureViewport(vp);
}
const files = fs.readdirSync(OUT_DIR).filter((f) => f.endsWith('.png'));
console.log(`\nDone! ${files.length} screenshots in ${OUT_DIR}/`);
})();
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @source "../pages/**/*.{js,ts,jsx,tsx}";
@source "../components/**/*.{js,ts,jsx,tsx}";
/* ParVagues palette */ /* ParVagues palette */
:root { :root {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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