Commit 8206199a by PLN (Algolia)

feat(app-router): migrate ParVagues onto the shared shell

Step 3 (group 2 of 3). Moves /parvagues, /parvagues/fiche,
/parvagues/live and /parvagues/live/[slug] to the App Router under a
single segment layout, retiring the old per-page components/parvagues/Layout.js.

- app/parvagues/layout.tsx: the ParVagues sub-shell (Syne via next/font,
  parvagues-root/noise-overlay wrapper, footer) inheriting the root shell.
  Header scroll-state and the `.reveal` IntersectionObserver are split into
  client leaves (ParvaguesHeader, RevealController); the latter re-arms on
  client-side navigation via usePathname so reveals keep working between pages.
- Pages become thin server components: data fetch + metadata + segment config
  (revalidate=60 on /parvagues and /parvagues/live/[slug] preserved;
  getStaticPaths -> generateStaticParams; per-page <title> -> metadata).
- /parvagues/live redirect: client useRouter.replace -> server redirect().
- /parvagues/live/[slug]: the interactive body (Prism, loupe, countdown,
  gallery) extracted to a client component (LiveEvent); the server page does
  the fs/data fetch.
- /parvagues/fiche: window.print + <style jsx global> body extracted to a
  client component (FicheContent) behind a server metadata wrapper.
- Marked the page-rendered feature components 'use client'
  (TourTimeline, MusicSection, VideoSection, BookingForm); Hero/BioSection/
  AsSeenAt stay server.
- Dropped og:type="music.musician" (not in Next's typed Metadata enum; it
  threw at prerender) — inherits the root "website" default; see note for Phase 2.

Build green; route-smoke 14/14. Only /cosmicfest remains on the Pages Router.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent e1595147
import Layout from '@/components/parvagues/Layout';
'use client';
import { FaEnvelope, FaPrint, FaLaptopCode, FaSlidersH, FaVolumeUp, FaVideo, FaPlug, FaClock } from 'react-icons/fa';
const SHOW_INFOS = [
......@@ -152,9 +153,9 @@ function StagePlot() {
);
}
export default function FicheTechnique() {
export default function FicheContent() {
return (
<Layout title="ParVagues — Fiche Technique">
<>
{/* Hero */}
<section className="pt-32 pb-10 md:pt-40 md:pb-14">
<div className="max-w-3xl mx-auto px-6 text-center">
......@@ -369,6 +370,6 @@ export default function FicheTechnique() {
a { text-decoration: none !important; }
}
`}</style>
</Layout>
</>
);
}
import FicheContent from './FicheContent';
export const metadata = {
title: { absolute: 'ParVagues — Fiche Technique' },
};
export default function FichePage() {
return <FicheContent />;
}
import { Syne } from 'next/font/google';
import { FaInstagram, FaYoutube, FaGitlab } from 'react-icons/fa';
import { SiMastodon } from 'react-icons/si';
import ParvaguesHeader from '@/components/parvagues/ParvaguesHeader';
import RevealController from '@/components/parvagues/RevealController';
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: 'http://nech.pl/Tidal', icon: FaGitlab, label: 'GitLab' },
{ href: 'https://youtube.com/@parvagues', icon: FaYoutube, label: 'YouTube' },
{ href: 'https://chaos.social/@PixelNoir', icon: SiMastodon, label: 'Mastodon' },
];
export const metadata = {
title: { absolute: 'ParVagues' },
description:
'ParVagues - Livecoding de musique électronique. Ondes binaires, plages sonores.',
// NOTE: the old shell set og:type="music.musician" via a raw <meta>; Next's
// typed Metadata API rejects that value, so we inherit the root "website"
// default for now. Phase 2 (ParVagues redesign) can revisit OG semantics.
icons: { icon: '/images/parvagues/logo.png' },
};
export default function ParvaguesLayout({ children }: { children: React.ReactNode }) {
return (
<div
className={`parvagues-root ${syne.variable} noise-overlay min-h-screen bg-[var(--surface)] text-[var(--text-primary)] selection:bg-[var(--neon-high)]/30 selection:text-white`}
>
<RevealController />
<ParvaguesHeader />
<main>{children}</main>
<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 { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/livesData';
import LiveEvent from '@/components/parvagues/LiveEvent';
export const revalidate = 60;
export function generateStaticParams() {
return getAllLives().map((live) => ({ slug: live.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const live = await getLiveData(slug);
const title = live.frontmatter?.title || slug;
return { title: { absolute: `${title} - ParVagues` } };
}
export default async function LivePage({ params }) {
const { slug } = await params;
const live = await getLiveData(slug);
const images = getLivesImages(slug);
const tracks = getLiveTracks(slug);
const isPostEvent =
new Date() > new Date((live.frontmatter?.date || '2099-01-01') + 'T23:59:59');
return (
<LiveEvent live={live} images={images} tracks={tracks} isPostEvent={isPostEvent} />
);
}
import { redirect } from 'next/navigation';
// /parvagues/live has no index of its own — send visitors to the main page.
export default function LiveIndex() {
redirect('/parvagues');
}
import { getAllLives } from '@/lib/livesData';
import Layout from '@/components/parvagues/Layout';
import Hero from '@/components/parvagues/Hero';
import BioSection from '@/components/parvagues/BioSection';
import AsSeenAt from '@/components/parvagues/AsSeenAt';
......@@ -8,21 +7,13 @@ import MusicSection from '@/components/parvagues/MusicSection';
import VideoSection from '@/components/parvagues/VideoSection';
import BookingForm from '@/components/parvagues/BookingForm';
export default function ParVagues({ lives }) {
return (
<Layout title="ParVagues - Musique Algorithmique">
<Hero />
<BioSection />
<AsSeenAt />
<TourTimeline lives={lives} />
<MusicSection />
<VideoSection />
<BookingForm />
</Layout>
);
}
export const revalidate = 60;
export async function getStaticProps() {
export const metadata = {
title: { absolute: 'ParVagues - Musique Algorithmique' },
};
export default function ParVaguesPage() {
const allLives = getAllLives();
// Only send fields needed by the landing page
......@@ -34,8 +25,15 @@ export async function getStaticProps() {
ctaURL: ctaURL || '',
}));
return {
props: { lives },
revalidate: 60,
};
return (
<>
<Hero />
<BioSection />
<AsSeenAt />
<TourTimeline lives={lives} />
<MusicSection />
<VideoSection />
<BookingForm />
</>
);
}
'use client';
import { useState } from 'react';
import { FaEnvelope } from 'react-icons/fa';
......
'use client';
import { useState, useEffect, useRef } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/livesData';
import Layout from '@/components/parvagues/Layout';
import Countdown from '@/components/parvagues/Countdown';
import ImageGallery from '@/components/parvagues/ImageGallery';
import { format } from 'date-fns';
......@@ -286,7 +285,6 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo
const location = fm.location || '';
return (
<Layout title={`${title} - ParVagues`}>
<div className="max-w-4xl mx-auto px-6 pt-24 pb-16">
{/* Header */}
<div className="text-center mb-12">
......@@ -366,33 +364,5 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo
</div>
)}
</div>
</Layout>
);
}
export async function getStaticPaths() {
const lives = getAllLives();
const paths = lives.map((live) => ({
params: { slug: live.slug },
}));
return { paths, fallback: false };
}
export async function getStaticProps({ params }) {
const live = await getLiveData(params.slug);
const images = getLivesImages(params.slug);
const tracks = getLiveTracks(params.slug);
const isPostEvent = new Date() > new Date((live.frontmatter?.date || '2099-01-01') + 'T23:59:59');
return {
props: {
live,
images,
tracks,
isPostEvent,
},
revalidate: 60,
};
}
'use client';
import { useState } from 'react';
import Image from 'next/image';
import {
......
import Head from 'next/head';
'use client';
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, FaGitlab } from 'react-icons/fa';
import { 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: 'http://nech.pl/Tidal', icon: FaGitlab, label: 'GitLab' },
{ href: 'https://youtube.com/@parvagues', icon: FaYoutube, label: 'YouTube' },
{ href: 'https://chaos.social/@PixelNoir', icon: SiMastodon, label: 'Mastodon' },
];
import { FaEnvelope } from 'react-icons/fa';
export default function Layout({ children, title = 'ParVagues' }) {
export default function ParvaguesHeader() {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
......@@ -30,38 +15,7 @@ export default function Layout({ children, title = 'ParVagues' }) {
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Scroll-triggered reveal for sections (only after JS hydration)
useEffect(() => {
// Mark wrapper so CSS knows JS is ready — sections stay visible without JS
document.documentElement.classList.add('reveal-ready');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return (
<div className={`parvagues-root ${syne.variable} noise-overlay 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="color-scheme" content="dark" />
<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
......@@ -107,41 +61,5 @@ export default function Layout({ children, title = 'ParVagues' }) {
</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>
);
}
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
// Scroll-triggered reveal for `.reveal` sections. Runs after hydration and
// re-arms on client-side navigation between ParVagues pages (the segment
// layout does not remount, so we key the observer on the pathname). Sections
// stay visible without JS — CSS only hides them once `.reveal-ready` is set.
export default function RevealController() {
const pathname = usePathname();
useEffect(() => {
document.documentElement.classList.add('reveal-ready');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [pathname]);
return null;
}
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
......
'use client';
import { useState } from 'react';
import { FaPlay, FaYoutube } from 'react-icons/fa';
......
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function LiveRedirect() {
const router = useRouter();
useEffect(() => {
router.replace('/parvagues');
}, [router]);
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl mb-4">Redirection...</h1>
<p>Vous allez être redirigé vers la page principale de ParVagues.</p>
</div>
</div>
);
}
\ No newline at end of file
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