Commit 3dfa3a52 by PLN (Algolia)

feat(app-router): migrate CosmicFest, remove Pages Router, update docs

Step 3 (group 3 of 3) — completes the App Router migration. The Pages
Router is now fully gone (no more pages/ or _app.js).

- /cosmicfest: server metadata wrapper + CosmicFestClient ('use client')
  for the modal/keyboard state. Swapped the deprecated next/image
  layout="intrinsic"/objectFit props for modern width/height + style.
- Deleted the dead legacy shells components/ParVaguesHeader.js and
  components/ParVaguesFooter.js (only ever referenced by commented-out
  imports in CosmicFest).
- tests/jest.setup.js: mock next/navigation (App Router hooks) instead of
  next/router.
- CLAUDE.md: Pages Router -> App Router throughout (global CSS from
  app/layout.tsx, server-components-by-default + 'use client' leaves,
  generateStaticParams, the root-shell + ParVagues-sub-layout split,
  next/navigation in tests).

Full App Router build green; route-smoke 14/14.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 8206199a
...@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ...@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is ## What this is
PLN's personal website (`pln-www`) — a Next.js **Pages Router** app that bundles a personal landing page together with several self-contained mini-apps and generative-art experiments. It grew organically; expect each section to have its own conventions. Sections worth knowing: PLN's personal website (`pln-www`) — a Next.js **App Router** app that bundles a personal landing page together with several self-contained mini-apps and generative-art experiments. It grew organically; expect each section to have its own conventions. Sections worth knowing:
- **`/` (index)** — personal landing page (bio, posts, talks). - **`/` (index)** — personal landing page (bio, posts, talks).
- **ParVagues** (`/parvagues`, `/parvagues/live/*`, `/parvagues/fiche`) — the most actively developed area: a live-coding musician's site (gig timeline, music/video, technical rider). Has its own component family under `components/parvagues/` and a separate `<Layout>`. Flagship of the in-progress redesign. - **ParVagues** (`/parvagues`, `/parvagues/live/*`, `/parvagues/fiche`) — the most actively developed area: a live-coding musician's site (gig timeline, music/video, technical rider). Has its own component family under `components/parvagues/` and a separate `<Layout>`. Flagship of the in-progress redesign.
...@@ -40,8 +40,8 @@ yarn preview:env:pull # vercel env pull .env.local (first-time local ...@@ -40,8 +40,8 @@ yarn preview:env:pull # vercel env pull .env.local (first-time local
- **Yarn 4 only.** `yarn.lock` is the single source of truth. Never create `package-lock.json` / `pnpm-lock.yaml`. Uses Yarn PnP (hence `jest-pnp-resolver` and the `.yarn/` dir). - **Yarn 4 only.** `yarn.lock` is the single source of truth. Never create `package-lock.json` / `pnpm-lock.yaml`. Uses Yarn PnP (hence `jest-pnp-resolver` and the `.yarn/` dir).
- **`@/` import alias** maps to the repo root (configured in `next.config.js` webpack and `jest.config.js` moduleNameMapper). Use `@/components/...`, `@/lib/...` instead of deep `../../` paths. - **`@/` import alias** maps to the repo root (configured in `next.config.js` webpack and `jest.config.js` moduleNameMapper). Use `@/components/...`, `@/lib/...` instead of deep `../../` paths.
- **Global CSS only from `pages/_app.js`.** Everywhere else use CSS Modules (`*.module.css`) or Tailwind utility classes. Tailwind v4 is active alongside the legacy `.module.css` files — both styling systems coexist. - **Global CSS only from `app/layout.tsx`** (the one root shell). Everywhere else use CSS Modules (`*.module.css`) or Tailwind utility classes. Tailwind v4 is active alongside the legacy `.module.css` files — both styling systems coexist.
- **One data-fetching strategy per page.** Never mix `getServerSideProps` with `getStaticProps`/`getStaticPaths` in the same file. If you see the "stale strategy" error after editing exports, delete `.next/` and restart. - **Server Components by default; `'use client'` only at the leaves.** Pages are server components that fetch data + export `metadata`/`generateStaticParams`; push interactivity (hooks, `window`, `<style jsx>`, `next/dynamic({ssr:false})`, embeds) into small client components. All routes are SSG/ISR — there is no SSR/`getServerSideProps`. `revalidate` lives as a segment export (`export const revalidate = 60`) on `/parvagues` and `/parvagues/live/[slug]`.
- **Content in `content/`, assets in `public/`.** Markdown lives under `content/SECTION/`; static assets under `public/images/SECTION/`, referenced by absolute path (`/images/...`). Don't hotlink persistent assets — copy them in. Avoid build-time network fetches for core UI. - **Content in `content/`, assets in `public/`.** Markdown lives under `content/SECTION/`; static assets under `public/images/SECTION/`, referenced by absolute path (`/images/...`). Don't hotlink persistent assets — copy them in. Avoid build-time network fetches for core UI.
- **Secrets via Vercel env**, never committed. `NEXT_PUBLIC_*` prefix only for browser-safe vars. - **Secrets via Vercel env**, never committed. `NEXT_PUBLIC_*` prefix only for browser-safe vars.
...@@ -49,20 +49,20 @@ yarn preview:env:pull # vercel env pull .env.local (first-time local ...@@ -49,20 +49,20 @@ yarn preview:env:pull # vercel env pull .env.local (first-time local
Markdown sections are loaded server-side at build via `gray-matter` (frontmatter) + `remark`/`remark-html` (body → HTML). The shared loader is `lib/utils.js`: Markdown sections are loaded server-side at build via `gray-matter` (frontmatter) + `remark`/`remark-html` (body → HTML). The shared loader is `lib/utils.js`:
- `getAllContentData(section, sorted)` — list with frontmatter only (used for index/listing pages). - `getAllContentData(section, sorted)` — list with frontmatter only (used for index/listing pages).
- `getAllContentIds(section)``getStaticPaths` shape. - `getAllContentIds(section)``[{ params: { id } }]` shape; map to `generateStaticParams()` via `.map(({ params }) => params)`.
- `getContentData(section, id)` — single item with rendered `contentHtml`. - `getContentData(section, id)` — single item with rendered `contentHtml`.
Thin per-section wrappers (`lib/posts.js`, `lib/hydras.js`, `lib/poems.js`, `lib/talks.js`) just bind a section name to these helpers. Dynamic content pages (`pages/post/[id].js`, `pages/poesie/[id].js`, `pages/hydra/[id].js`) pair `getStaticPaths` + `getStaticProps`. Thin per-section wrappers (`lib/posts.js`, `lib/hydras.js`, `lib/poems.js`, `lib/talks.js`) just bind a section name to these helpers. Dynamic content pages (`app/post/[id]/page.js`, `app/poesie/[id]/page.js`, `app/hydra/[id]/page.js`) are async server components that pair `generateStaticParams()` + a `params`-driven fetch (`params` is a Promise — `await` it).
**ParVagues "lives" are a separate, richer model** (`lib/livesData.js`, NOT the generic loader): Markdown files organized by year under `content/lives/YYYY/slug.md`, with optional `content/lives/YYYY/slug/tracks.json` and gig photos under `public/images/parvagues/lives/YYYY/slug/`. Frontmatter carries gig metadata (date, time, location, audio/video/instagram/archive links, tags). `getAllLives()` aggregates across all year folders, sorted newest-first. **ParVagues "lives" are a separate, richer model** (`lib/livesData.js`, NOT the generic loader): Markdown files organized by year under `content/lives/YYYY/slug.md`, with optional `content/lives/YYYY/slug/tracks.json` and gig photos under `public/images/parvagues/lives/YYYY/slug/`. Frontmatter carries gig metadata (date, time, location, audio/video/instagram/archive links, tags). `getAllLives()` aggregates across all year folders, sorted newest-first.
## Architecture notes ## Architecture notes
- **Hydra & p5 sketches**: canvas/WebGL code must be client-only — imported via `next/dynamic` with `ssr: false` (see `pages/hydra/[id].js``components/hydra-view.js`). - **Hydra & p5 sketches**: canvas/WebGL code must be client-only — `next/dynamic({ ssr: false })` is only valid inside a client component, so it lives in a `'use client'` leaf (see `app/hydra/[id]/page.js``app/hydra/[id]/HydraClient.js``components/hydra-view.js`; `/fleurs` follows the same server-page → client-leaf split).
- **Layouts are not shared across sections.** There's a top-level `components/layout.js` and a separate `components/parvagues/Layout.js` — pick the one matching the section you're editing. - **One root shell + a ParVagues sub-layout.** `app/layout.tsx` is the single `<html>/<body>` shell (global CSS + shared `metadata`). The landing/content pages wrap their content in the `components/layout.js` client component (header/footer/back-button). ParVagues has its own segment layout `app/parvagues/layout.tsx` (Syne font, `parvagues-root` wrapper, scroll header + reveal observer as client leaves). The legacy `ParVaguesHeader`/`ParVaguesFooter` and `components/parvagues/Layout.js` shells are gone.
## Testing ## Testing
Jest (`jest.config.js`) uses `next/jest` (SWC transform) + jsdom, RTL matchers, and `next-router-mock`. `tests/jest.setup.js` mocks `next/router` and `next/dynamic` (renders null stub). Unit tests in `tests/unit/`, Playwright e2e in `tests/e2e/` (excluded from Jest via `testPathIgnorePatterns`). **The test infra exists but there is currently 0 coverage** — all tests were Dunbar's and were removed with it. Re-establishing a baseline (starting with ParVagues) is part of the redesign. Jest (`jest.config.js`) uses `next/jest` (SWC transform) + jsdom, RTL matchers. `tests/jest.setup.js` mocks `next/navigation` (App Router hooks: `useRouter`/`usePathname`/`useSearchParams`/`redirect`) and `next/dynamic` (renders null stub). Unit tests in `tests/unit/`, Playwright e2e in `tests/e2e/` (excluded from Jest via `testPathIgnorePatterns`). **The test infra exists but there is currently 0 coverage** — all tests were Dunbar's and were removed with it. Re-establishing a baseline (starting with ParVagues) is part of the redesign.
Note: `tsconfig.json` has `strict: false` — TypeScript is loosely applied; most app code is plain `.js`/`.jsx`. Note: `tsconfig.json` has `strict: false` — TypeScript is loosely applied; most app code is plain `.js`/`.jsx`.
import Head from 'next/head'; 'use client';
import Image from 'next/image'; // Using Next.js Image component for optimization import Image from 'next/image'; // Using Next.js Image component for optimization
import { useState, useEffect } from 'react'; // Added useState and useEffect import { useState, useEffect } from 'react';
import styles from '../../styles/cosmicfest.module.css'; import styles from '@/styles/cosmicfest.module.css';
import Countdown from '../../components/cosmicfest/Countdown'; import Countdown from '@/components/cosmicfest/Countdown';
// import ParVaguesFooter from '../../components/ParVaguesFooter'; // Optional: if you want to reuse the main site footer
// Helper function to calculate next June 21st // Helper function to calculate next June 21st
const getNextJune21st = () => { const getNextJune21st = () => {
...@@ -17,7 +17,7 @@ const getNextJune21st = () => { ...@@ -17,7 +17,7 @@ const getNextJune21st = () => {
return nextJune21; return nextJune21;
}; };
export default function CosmicFestHome() { export default function CosmicFestClient() {
const nextFestivalDate = getNextJune21st(); const nextFestivalDate = getNextJune21st();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [modalImageSrc, setModalImageSrc] = useState(''); const [modalImageSrc, setModalImageSrc] = useState('');
...@@ -87,12 +87,6 @@ export default function CosmicFestHome() { ...@@ -87,12 +87,6 @@ export default function CosmicFestHome() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Head>
<title>cosmicfest</title>
<meta name="description" content="cosmicfest - festival indé de musique et création numérique à Labenne Océan. 21 juin." />
<link rel="icon" href="/favicon.ico" /> {/* Consider a specific cosmicfest favicon */}
</Head>
<main className={styles.main}> <main className={styles.main}>
<header className={styles.hero}> <header className={styles.hero}>
{/* Hero image is set via CSS background for now */} {/* Hero image is set via CSS background for now */}
...@@ -219,11 +213,8 @@ export default function CosmicFestHome() { ...@@ -219,11 +213,8 @@ export default function CosmicFestHome() {
<button className={styles.modalCloseButton} onClick={closeModal} aria-label="Close image viewer"> <button className={styles.modalCloseButton} onClick={closeModal} aria-label="Close image viewer">
&times; &times;
</button> </button>
{/* For Next/Image, provide intrinsic image width/height if known, or use layout fill with sized parent.
Using layout="responsive" with some default aspect ratio here.
Actual display size will be controlled by CSS for modalImage container. */}
<div className={styles.modalImageContainer}> <div className={styles.modalImageContainer}>
<Image src={modalImageSrc} alt={modalImageAlt} layout="intrinsic" width={1200} height={900} objectFit="contain" /> <Image src={modalImageSrc} alt={modalImageAlt} width={1200} height={900} style={{ width: '100%', height: 'auto', objectFit: 'contain' }} />
</div> </div>
{modalImageAlt && <p className={styles.modalCaption}>{modalImageAlt}</p>} {modalImageAlt && <p className={styles.modalCaption}>{modalImageAlt}</p>}
</div> </div>
...@@ -231,15 +222,11 @@ export default function CosmicFestHome() { ...@@ -231,15 +222,11 @@ export default function CosmicFestHome() {
)} )}
<footer className={styles.footer}> <footer className={styles.footer}>
<a <a href="/parvagues/">
href="/parvagues/"
>
vibe ~ vibe ~
<span style={{fontWeight: 'bold'}}>parvagues</span> <span style={{fontWeight: 'bold'}}>parvagues</span>
{/* <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} /> You can use ParVagues logo here */}
</a> </a>
</footer> </footer>
{/* <ParVaguesFooter /> */} {/* Or use the existing footer */}
</div> </div>
); );
} }
import CosmicFestClient from './CosmicFestClient';
export const metadata = {
title: { absolute: 'cosmicfest' },
description:
'cosmicfest - festival indé de musique et création numérique à Labenne Océan. 21 juin.',
};
export default function CosmicFestPage() {
return <CosmicFestClient />;
}
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { FaEnvelope, FaInstagram, FaTwitter, FaYoutube } from 'react-icons/fa';
import { SiMastodon, SiBluesky } from 'react-icons/si';
import styles from '@/styles/parvagues.module.css';
export default function ParVaguesFooter() {
const socialLinks = [
{ icon: <FaEnvelope />, label: 'email', url: 'mailto:parvagues@nech.pl' },
{ icon: <SiMastodon />, label: 'mastodon', url: 'https://chaos.social/@PixelNoir' },
{ icon: <FaTwitter />, label: 'twitter', url: 'https://x.com/ParVagues' },
{ icon: <SiBluesky />, label: 'bluesky', url: '#' },
{ icon: <FaInstagram />, label: 'instagram', url: 'https://instagram.com/parvagues.mp3' },
{ icon: <FaYoutube />, label: 'youtube', url: 'https://www.youtube.com/@parvagues' }
];
const year = new Date().getFullYear();
return (
<footer className="bg-black border-t border-[#d900ff]/20 py-8 relative"> {/* Removed overflow-hidden */}
<div className={styles.neonGradient}></div>
<div className="max-w-6xl mx-auto px-4 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-start"> {/* Changed to md:grid-cols-3 and items-start */}
{/* About Column */}
<div className="md:col-span-1"> {/* Explicit column span */}
<p className="text-gray-400 text-sm mb-4">
Livecoding de musique libre<br />
Performances algorithmiques en direct.
</p>
<p className="text-xs text-gray-500">
&copy; {year} ParVagues. Tous droits réservés.
</p>
</div>
{/* Logo Column (New) */}
<div className="md:col-span-1 flex flex-col items-center justify-center"> {/* This centers the block below */}
<div className="relative mb-4 flex justify-center"> {/* This ensures the image within this block is centered */}
<Image
src="/images/parvagues/logo.png"
alt="ParVagues Logo"
width={100} // Reduced logo size
height={100} // Reduced logo size
/>
</div>
</div>
{/* Social Column */}
<div className="md:col-span-1 flex flex-col items-center md:items-end"> {/* Explicit column span */}
<p className="text-gray-400 text-sm mb-3 text-center md:text-right">Restons connectés :</p>
<div className="flex flex-wrap gap-3 justify-center md:justify-end"> {/* Reduced gap */}
{socialLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="bg-gray-800 hover:bg-purple-700/70 text-white p-2.5 rounded-full transition-colors shadow-md hover:shadow-purple-500/40" // Slightly smaller padding
aria-label={link.label}
>
{React.cloneElement(link.icon, { size: '1.1em' })} {/* Slightly smaller icons */}
</a>
))}
</div>
</div>
</div>
{/* Navigation Links - more compact and centered */}
<div className="w-full mt-8 pt-6 border-t border-purple-500/10"> {/* Added top margin, padding and border */}
<div className="flex justify-center items-center space-x-6 text-xs tracking-wider uppercase flex-wrap gap-y-2"> {/* Reduced space-x, added gap-y */}
<Link href="/parvagues#music" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
Musique
</Link>
<Link href="/parvagues#performances" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
Performances
</Link>
<Link href="/parvagues#about" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
À Propos
</Link>
<a
href="mailto:parvagues@nech.pl?subject=Booking Request"
className="text-gray-400 hover:text-purple-400 transition-colors px-2"
>
Réserver
</a>
</div>
</div>
</div>
</footer>
);
}
\ No newline at end of file
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { FaEnvelope } from 'react-icons/fa';
import styles from '@/styles/parvagues.module.css';
import { useRouter } from 'next/router';
// Custom hook to track scroll position
function useScrolledPast(threshold = 100) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > threshold);
};
// Initial check
onScroll();
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [threshold]);
return scrolled;
}
export default function ParVaguesHeader({ eventName = null, title = null }) {
const router = useRouter();
const isHome = router.pathname === '/parvagues';
const showInHeader = useScrolledPast(300); // Threshold for showing title on scroll
const headerTitle = title || eventName || 'ParVagues';
return (
<header className={`sticky top-0 left-0 w-full z-50 bg-black/80 backdrop-blur-md border-b border-[#d900ff]/20 ${styles.headerContainer}`}>
<div className={`${styles.neonGradient} opacity-5 absolute inset-0`}></div>
<div className="max-w-full mx-auto px-4 sm:px-6 flex items-center justify-between h-16"> {/* Adjusted padding for responsiveness */}
{/* Logo and Title */}
<Link href="/parvagues" className="flex items-center group flex-shrink-0"> {/* Added flex-shrink-0 */}
<div className="h-10 w-10 relative"> {/* Simplified logo div */}
<Image
src="/images/parvagues/logo.png"
alt="ParVagues Logo"
width={40}
height={40}
className="object-contain transition-all duration-300 group-hover:filter group-hover:drop-shadow-[0_0_8px_rgba(217,0,255,0.7)]"
/>
</div>
<div className="overflow-hidden ml-3">
<span
className={`text-white font-bold transition-all duration-500 whitespace-nowrap ${ /* Added whitespace-nowrap */
showInHeader || !isHome ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full'
}`}
style={{
textShadow: '0 0 5px rgba(217, 0, 255, 0.7), 0 0 10px rgba(217, 0, 255, 0.5)',
color: 'var(--neon-high)'
}}
>
{headerTitle}
</span>
</div>
</Link>
{/* Navigation Links */}
{/* Ensure this nav doesn't cause overflow issues on very small screens - links might need to wrap or hide */}
<nav className="flex-grow flex justify-center items-center space-x-4 md:space-x-6 text-sm tracking-wider mx-2 sm:mx-4"> {/* Added horizontal margin */}
<Link href="/parvagues#music" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}> {/* Added padding for touch targets */}
Music
</Link>
<Link href="/parvagues#performances" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}>
Performances
</Link>
<Link href="/parvagues#about" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}>
About
</Link>
</nav>
{/* CTA button */}
<Link
href="/book"
className={`${styles.outlineButton} ${styles.bookButton} py-2 px-3 sm:px-4 text-xs sm:text-sm flex items-center whitespace-nowrap flex-shrink-0`} /* Adjusted padding, font size, added flex-shrink-0 */
>
<FaEnvelope className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4" /> {/* Responsive icon size */}
<span>Book</span>
</Link>
</div>
</header>
);
}
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" /> /// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
......
import '../styles/globals.css'
import '../styles/main.css'
import '../styles/masonry.css'
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
// Mock Next.js router for unit/integration tests // Mock the App Router navigation hooks for unit/integration tests.
jest.mock('next/router', () => require('next-router-mock')); // (The app migrated off the Pages Router's next/router.)
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
redirect: jest.fn(),
notFound: jest.fn(),
}));
// Mock next/dynamic to avoid async loading/act warnings in unit tests. // Mock next/dynamic to avoid async loading/act warnings in unit tests.
// It renders a null stub for dynamically imported components. // It renders a null stub for dynamically imported components.
......
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