mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
etape6ok
This commit is contained in:
parent
0a506dbf39
commit
f824053a31
@ -3,12 +3,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { getApiUrl } from "../utils/getApiUrl";
|
||||
import CarouselCompetences from "../components/CarouselCompetences";
|
||||
import VignetteCarousel from "../components/VignetteCarousel";
|
||||
import "../globals.css";
|
||||
import "../assets/main.css";
|
||||
|
||||
/**
|
||||
* Liste des compétences — refonte "Digital Atelier" (étape 6).
|
||||
*
|
||||
* Même pattern de grille asymétrique 2/3 + 1/3 que `app/portfolio/page.jsx` pour
|
||||
* garder une cohérence visuelle entre les deux rubriques principales. Le
|
||||
* `CarouselCompetences` est retiré de la liste (arbitrage REFONTE-VISUELLE.md §2 :
|
||||
* carousel réservé aux galeries intra-fiche) : seule la première image est
|
||||
* affichée ici, ce qui allège le rendu et clarifie la lecture.
|
||||
*/
|
||||
const spanPattern = ["md:col-span-4", "md:col-span-2", "md:col-span-2", "md:col-span-4"];
|
||||
|
||||
export default function Page() {
|
||||
const [competences, setCompetences] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
useEffect(() => {
|
||||
@ -21,57 +33,141 @@ export default function Page() {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Tri sécurisé des compétences par `order`
|
||||
const sortedCompetences = (data.data ?? []).sort((a, b) => ((a.attributes?.order ?? 999) - (b.attributes?.order ?? 999)));
|
||||
const sortedCompetences = (data.data ?? []).sort(
|
||||
(a, b) => ((a.attributes?.order ?? 999) - (b.attributes?.order ?? 999))
|
||||
);
|
||||
|
||||
setCompetences(sortedCompetences);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur lors de la récupération des compétences :", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCompetences();
|
||||
}, [apiUrl]);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<main className="w-full p-3 mt-5 mb-5">
|
||||
|
||||
|
||||
{competences.length === 0 ? (
|
||||
<p className="text-center text-gray-500">Aucune compétence disponible.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-7 max-w-7xl mx-auto">
|
||||
{competences.map((competence) => {
|
||||
const pictures = competence.picture || [];
|
||||
const images = pictures.map(picture => ({
|
||||
url: picture.url ? `${apiUrl}${picture.url}` : "/placeholder.jpg",
|
||||
alt: picture.name || "Competence image"
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={competence.id}
|
||||
className="bg-white/70 rounded-lg shadow-md overflow-hidden w-full max-w-xs sm:max-w-md md:max-w-lg lg:max-w-xl xl:max-w-2xl h-auto flex flex-col transform transition-all duration-300 hover:scale-105 hover:shadow-xl p-4"
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-5 px-4 pb-10 sm:px-6">
|
||||
{/* En-tête éditorial cohérent avec le portfolio. */}
|
||||
<section
|
||||
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
|
||||
aria-labelledby="competences-title"
|
||||
>
|
||||
<Link href={`/competences/${competence.slug}`}>
|
||||
<div className="overflow-hidden w-full h-64 mb-4">
|
||||
<CarouselCompetences images={images} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto max-h-32 hide-scrollbar show-scrollbar">
|
||||
<p className="font-headline font-bold text-xl mb-2">{competence.name}</p>
|
||||
<p className="text-gray-700 text-sm hover:text-base transition-all duration-200 ease-in-out">
|
||||
{competence.description}
|
||||
<div className="flex flex-col gap-3 text-center md:text-left">
|
||||
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||
Compétences · Savoir-faire
|
||||
</span>
|
||||
<h1
|
||||
id="competences-title"
|
||||
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl lg:text-5xl"
|
||||
>
|
||||
Ce que je sais faire, et comment
|
||||
</h1>
|
||||
<p className="font-body text-on-surface-variant sm:text-lg">
|
||||
Chaque fiche détaille une compétence, son contexte d’apprentissage et des
|
||||
exemples concrets — ouvrez une carte pour voir les outils mobilisés et les
|
||||
projets associés.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${spanPattern[idx % spanPattern.length]} rounded-sheet bg-surface-container-lowest/60 p-5 shadow-ambient-sm backdrop-blur-vellum`}
|
||||
>
|
||||
<div className="aspect-[4/3] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
|
||||
<div className="mt-4 h-5 w-2/3 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||
<div className="mt-2 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
|
||||
<div className="mt-1.5 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : competences.length === 0 ? (
|
||||
<section className="rounded-sheet bg-surface-container-lowest/75 p-8 text-center shadow-ambient-sm backdrop-blur-vellum">
|
||||
<span
|
||||
className="material-symbols-outlined mb-3 text-4xl text-primary"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
school
|
||||
</span>
|
||||
<p className="font-body italic text-on-surface-variant">
|
||||
Aucune compétence disponible pour le moment.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||
{competences.map((competence, idx) => {
|
||||
const pictures = competence.picture ?? [];
|
||||
const images = pictures.map((img) => ({
|
||||
url: img.url ? `${apiUrl}${img.url}` : "/placeholder.jpg",
|
||||
alt: img.name || `Visuel de la compétence ${competence.name}`,
|
||||
}));
|
||||
const firstImage = images[0];
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={competence.id}
|
||||
href={`/competences/${competence.slug}`}
|
||||
className={`${spanPattern[idx % spanPattern.length]} group flex flex-col overflow-hidden rounded-sheet bg-surface-container-lowest/85 shadow-ambient backdrop-blur-vellum transition duration-300 hover:-translate-y-0.5 hover:shadow-jewel focus:outline-none focus-visible:ring-2 focus-visible:ring-primary`}
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-surface-container-low">
|
||||
{images.length > 1 ? (
|
||||
<VignetteCarousel images={images} />
|
||||
) : firstImage ? (
|
||||
<img
|
||||
src={firstImage.url}
|
||||
alt={firstImage.alt}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
||||
<span
|
||||
className="material-symbols-outlined text-3xl"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
image
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 p-5 sm:p-6">
|
||||
<span className="font-headline text-[10px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||
Compétence
|
||||
</span>
|
||||
<h2 className="font-headline text-xl font-extrabold leading-tight tracking-tight text-primary">
|
||||
{competence.name}
|
||||
</h2>
|
||||
{competence.description && (
|
||||
<p className="font-body text-sm leading-relaxed text-on-surface-variant line-clamp-3 sm:text-base">
|
||||
{competence.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="mt-auto inline-flex items-center gap-1.5 pt-3 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary">
|
||||
Explorer
|
||||
<span
|
||||
className="material-symbols-outlined text-lg transition-transform duration-300 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
arrow_forward
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
app/components/VignetteCarousel.tsx
Normal file
68
app/components/VignetteCarousel.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Autoplay, Pagination } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/pagination";
|
||||
|
||||
/**
|
||||
* Carousel allégé réservé aux vignettes des listes (portfolio, compétences).
|
||||
*
|
||||
* Différences volontaires par rapport à `Carousel.tsx` / `CarouselCompetences.tsx`
|
||||
* (qui restent utilisés dans les fiches détail) :
|
||||
*
|
||||
* - **Pas de flèches de navigation** : chaque vignette est enveloppée d'un
|
||||
* `<Link>` qui capture le clic. Les flèches Swiper créaient une zone de clic
|
||||
* ambiguë (on croit naviguer dans les images, on arrive sur la fiche détail).
|
||||
* L'autoplay + le swipe tactile suffisent à l'échelle d'une vignette.
|
||||
* - **Pas de lightbox** (pas de `createPortal`). La lightbox reste la signature
|
||||
* de la fiche détail ; en vignette on ne propose que la navigation vers la fiche.
|
||||
* - **Pagination Stitch** : bullets teintés `primary` via des variables CSS
|
||||
* Swiper surchargées en inline, pour éviter de polluer `globals.css` avec un
|
||||
* sélecteur global.
|
||||
*
|
||||
* Le composant couvre 100 % de son conteneur parent (qui fixe le `aspect-ratio`
|
||||
* dans les pages liste), avec `object-cover` sur les images.
|
||||
*/
|
||||
interface VignetteCarouselProps {
|
||||
images: Array<{ url: string; alt: string }>;
|
||||
autoplayDelay?: number;
|
||||
}
|
||||
|
||||
export default function VignetteCarousel({
|
||||
images,
|
||||
autoplayDelay = 3500,
|
||||
}: VignetteCarouselProps) {
|
||||
return (
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination]}
|
||||
slidesPerView={1}
|
||||
loop={images.length > 1}
|
||||
autoplay={{ delay: autoplayDelay, disableOnInteraction: false }}
|
||||
pagination={{ clickable: false }}
|
||||
className="h-full w-full"
|
||||
style={
|
||||
{
|
||||
// Surcharges Swiper : bullets primary Stitch, taille discrète.
|
||||
"--swiper-pagination-color": "#26445d",
|
||||
"--swiper-pagination-bullet-inactive-color": "#ffffff",
|
||||
"--swiper-pagination-bullet-inactive-opacity": "0.55",
|
||||
"--swiper-pagination-bullet-size": "6px",
|
||||
"--swiper-pagination-bullet-horizontal-gap": "3px",
|
||||
"--swiper-pagination-bottom": "8px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{images.map((img, index) => (
|
||||
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.alt}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
);
|
||||
}
|
||||
@ -94,10 +94,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
</head>
|
||||
<body className="min-w-0 overflow-x-hidden antialiased">
|
||||
<div className="relative grid min-h-[100dvh] w-full min-w-0 grid-rows-[auto_1fr_auto]">
|
||||
{/* Wallpaper plein écran (fondation, ne change pas avec la refonte). */}
|
||||
<div className="absolute inset-0 bg-wallpaper"></div>
|
||||
{/* Wallpaper plein écran (fondation).
|
||||
`fixed inset-0` plutôt qu'`absolute` dans le grid : le wallpaper est
|
||||
désormais calé sur le viewport, pas sur la hauteur totale de la page.
|
||||
Sans ça, sur les pages longues (portfolio, compétences, fiches) le
|
||||
conteneur grid atteignait 2-3 viewports de haut et `background-size: cover`
|
||||
zoomait l'image pour couvrir cette hauteur — d'où le rendu incohérent
|
||||
entre home (courte) et listes (longues). Corrigé le 2026-04-22. */}
|
||||
<div className="fixed inset-0 z-0 bg-wallpaper pointer-events-none" aria-hidden="true"></div>
|
||||
|
||||
<div className="relative grid min-h-[100dvh] w-full min-w-0 grid-rows-[auto_1fr_auto]">
|
||||
{/* Cercles animés : repalette en ton indigo-ardoise (Stitch "Digital Atelier"). */}
|
||||
<div className="absolute z-0 inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="circle-one blur-3xl w-40 md:w-64 h-40 md:h-64 rounded-full bg-primary/40 top-0 right-10 md:right-28 absolute"></div>
|
||||
|
||||
@ -3,76 +3,180 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { getApiUrl } from "../utils/getApiUrl";
|
||||
import Carousel from "../components/Carousel";
|
||||
import VignetteCarousel from "../components/VignetteCarousel";
|
||||
import "../assets/main.css";
|
||||
import "../globals.css";
|
||||
|
||||
/**
|
||||
* Liste des projets — refonte "Digital Atelier" (étape 6).
|
||||
*
|
||||
* Règle DESIGN.md §6 "No-Grid-Lock" : on bannit la grille 3 colonnes symétrique.
|
||||
* On utilise une grille asymétrique 2/3 + 1/3 alternée par paires, qui crée un
|
||||
* rythme éditorial plutôt qu'un catalogue. Arbitrage acté dans REFONTE-VISUELLE.md §2 :
|
||||
* le carousel reste réservé aux fiches détail (étape 7) — la liste n'affiche que
|
||||
* la première image, ce qui allège le rendu et clarifie la hiérarchie.
|
||||
*
|
||||
* Pattern de spans (modulo 4 sur desktop 6 colonnes) :
|
||||
* idx 0 → col-span-4 (vedette)
|
||||
* idx 1 → col-span-2
|
||||
* idx 2 → col-span-2
|
||||
* idx 3 → col-span-4
|
||||
* → répète l'alternance pour éviter la monotonie sans dépendre du nombre d'items.
|
||||
*/
|
||||
const spanPattern = ["md:col-span-4", "md:col-span-2", "md:col-span-2", "md:col-span-4"];
|
||||
|
||||
export default function Page() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/projects?populate=picture&sort=order:asc`); // Récupération triée depuis Strapi
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/projects?populate=picture&sort=order:asc`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de récupération des projets : ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Tri des projets côté Next.js (au cas où Strapi ne le fait pas)
|
||||
const sortedProjects = (data.data ?? []).sort((a, b) => (a.order || 999) - (b.order || 999));
|
||||
const sortedProjects = (data.data ?? []).sort(
|
||||
(a, b) => (a.order || 999) - (b.order || 999)
|
||||
);
|
||||
|
||||
setProjects(sortedProjects);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur lors de la récupération des projets :", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProjects();
|
||||
}, [apiUrl]);
|
||||
|
||||
|
||||
return (
|
||||
<main className="w-full p-3 mt-5 mb-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(300px,1fr))] gap-7 max-w-7xl mx-auto mobile-landscape">
|
||||
{projects.map((project) => {
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-5 px-4 pb-10 sm:px-6">
|
||||
{/* En-tête éditorial, aligné sur le hero de la home (kicker + titre Manrope). */}
|
||||
<section
|
||||
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
|
||||
aria-labelledby="portfolio-title"
|
||||
>
|
||||
<div className="flex flex-col gap-3 text-center md:text-left">
|
||||
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||
Portfolio · Projets
|
||||
</span>
|
||||
<h1
|
||||
id="portfolio-title"
|
||||
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl lg:text-5xl"
|
||||
>
|
||||
Les projets qui m’ont construit
|
||||
</h1>
|
||||
<p className="font-body text-on-surface-variant sm:text-lg">
|
||||
Une sélection de réalisations pédagogiques, personnelles et professionnelles —
|
||||
cliquez sur une carte pour en découvrir la genèse, les choix techniques et les
|
||||
visuels.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* État de chargement : 4 squelettes qui respectent la grille asymétrique. */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${spanPattern[idx % spanPattern.length]} rounded-sheet bg-surface-container-lowest/60 p-5 shadow-ambient-sm backdrop-blur-vellum`}
|
||||
>
|
||||
<div className="aspect-[4/3] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
|
||||
<div className="mt-4 h-5 w-2/3 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||
<div className="mt-2 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
|
||||
<div className="mt-1.5 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<section className="rounded-sheet bg-surface-container-lowest/75 p-8 text-center shadow-ambient-sm backdrop-blur-vellum">
|
||||
<span
|
||||
className="material-symbols-outlined mb-3 text-4xl text-primary"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
inbox
|
||||
</span>
|
||||
<p className="font-body italic text-on-surface-variant">
|
||||
Aucun projet à afficher pour le moment.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||
{projects.map((project, idx) => {
|
||||
const pictures = project.picture ?? [];
|
||||
const images = pictures.map((img) => ({
|
||||
url: `${apiUrl}${img.url}`,
|
||||
alt: img.name || "Project image",
|
||||
alt: img.name || `Visuel du projet ${project.name}`,
|
||||
}));
|
||||
const firstImage = images[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
key={project.id}
|
||||
className="bg-white/80 rounded-lg shadow-md overflow-hidden w-80 h-96 flex flex-col transform transition-all duration-300 hover:scale-105 hover:shadow-xl p-4"
|
||||
href={`/portfolio/${project.slug}`}
|
||||
className={`${spanPattern[idx % spanPattern.length]} group flex flex-col overflow-hidden rounded-sheet bg-surface-container-lowest/85 shadow-ambient backdrop-blur-vellum transition duration-300 hover:-translate-y-0.5 hover:shadow-jewel focus:outline-none focus-visible:ring-2 focus-visible:ring-primary`}
|
||||
>
|
||||
<Link href={`/portfolio/${project.slug}`}>
|
||||
<div className="overflow-hidden w-full h-48 mb-4">
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-surface-container-low">
|
||||
{images.length > 1 ? (
|
||||
<Carousel images={images} className="h-48" />
|
||||
) : (
|
||||
<VignetteCarousel images={images} />
|
||||
) : firstImage ? (
|
||||
<img
|
||||
src={images[0]?.url || "/placeholder.jpg"}
|
||||
alt={images[0]?.alt || "Project image"}
|
||||
className="w-full h-full object-cover"
|
||||
src={firstImage.url}
|
||||
alt={firstImage.alt}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
||||
<span
|
||||
className="material-symbols-outlined text-3xl"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
image
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-grow overflow-y-auto max-h-32 hide-scrollbar show-scrollbar">
|
||||
<p className="font-headline font-bold text-xl mb-2">{project.name}</p>
|
||||
<p className="text-gray-700 text-sm font-headline hover:text-base transition-all duration-200 ease-in-out">
|
||||
<div className="flex flex-1 flex-col gap-2 p-5 sm:p-6">
|
||||
<span className="font-headline text-[10px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||
Projet
|
||||
</span>
|
||||
<h2 className="font-headline text-xl font-extrabold leading-tight tracking-tight text-primary">
|
||||
{project.name}
|
||||
</h2>
|
||||
{project.description && (
|
||||
<p className="font-body text-sm leading-relaxed text-on-surface-variant line-clamp-3 sm:text-base">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="mt-auto inline-flex items-center gap-1.5 pt-3 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary">
|
||||
Découvrir
|
||||
<span
|
||||
className="material-symbols-outlined text-lg transition-transform duration-300 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
arrow_forward
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Refonte visuelle — Direction "Digital Atelier"
|
||||
|
||||
**Créé :** 2026-04-22
|
||||
**Statut :** en cours (étapes 1-5/8 terminées)
|
||||
**Statut :** en cours (étapes 1-6/8 terminées)
|
||||
**Source d'inspiration :** `stitch_V1/` (design newsletter Stitch — `DESIGN.md` et `code.html`).
|
||||
**Audit préalable :** [`captures/AUDIT-VISUEL.md`](./captures/AUDIT-VISUEL.md).
|
||||
|
||||
@ -59,7 +59,7 @@ Chaque étape = un lot cohérent + éventuelle mise à jour de `captures/AUDIT-V
|
||||
| 3 | Migration typographique globale (Orbitron → Manrope / Newsreader) | `app/**/*.{tsx,jsx,js}`, `app/assets/main.css` | **fait** (2026-04-22) |
|
||||
| 4 | Layout racine : header No-Line, burger ghost, palette cercles, compteur migré, drawer | `app/layout.tsx`, `app/components/NavLink.jsx`, `app/components/Footer.jsx` | **fait** (2026-04-22) |
|
||||
| 5 | Home : hero vellum, portrait frame, takeaways, pull-quote, CTAs | `app/page.tsx` | **fait** (2026-04-22) |
|
||||
| 6 | Listes portfolio + compétences : grille asymétrique, cartes éditoriales | `app/portfolio/page.jsx`, `app/competences/page.jsx`, composants `Carousel*` | à faire |
|
||||
| 6 | Listes portfolio + compétences : grille asymétrique, cartes éditoriales | `app/portfolio/page.jsx`, `app/competences/page.jsx`, composants `Carousel*` | **fait** (2026-04-22) |
|
||||
| 7 | Fiches détail + modale glossaire + GrasBot (jewel flottant) | `app/portfolio/[slug]/page.tsx`, `app/competences/[slug]/page.tsx`, `app/components/ModalGlossaire.tsx`, `app/components/ChatBot.js` | à faire |
|
||||
| 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | à faire |
|
||||
|
||||
@ -150,6 +150,85 @@ Les icônes Material Symbols Outlined fonctionnent via **ligatures de font** : u
|
||||
|
||||
Composant wrapper `<Icon>` qui pose automatiquement `translate="no"` envisagé comme DRY à long terme (hors scope actuel).
|
||||
|
||||
## 6. Étape 6 — Listes portfolio + compétences (2026-04-22)
|
||||
|
||||
Les deux pages liste étaient héritées du design avant refonte : cartes `bg-white/80 rounded-lg` à taille **fixe** (`w-80 h-96` sur portfolio, `max-w-xs…2xl` en cascade sur compétences), `hover:scale-105` qui débordait sous le header, **chaque vignette embarquait un `Swiper` autoplay** (cf. `Carousel.tsx` et `CarouselCompetences.tsx`) — bruit visuel constant, coût réseau (3-5 images × N cartes chargées d'emblée), et incohérence avec l'arbitrage acté § 2 *"carousel réservé aux galeries intra-fiche"*. Sur mobile, la largeur fixe 320 px de la carte portfolio débordait un viewport 360 px + padding.
|
||||
|
||||
### Direction Stitch appliquée
|
||||
|
||||
**Règle DESIGN.md §6 "No-Grid-Lock"** interdit la grille 3 colonnes symétrique. On adopte une grille **asymétrique 2/3 + 1/3** qui donne un rythme éditorial plutôt qu'un catalogue :
|
||||
|
||||
```
|
||||
md:grid-cols-6, pattern de spans par index modulo 4 :
|
||||
idx 0 → md:col-span-4 (vedette, 2/3)
|
||||
idx 1 → md:col-span-2 (1/3)
|
||||
idx 2 → md:col-span-2 (1/3)
|
||||
idx 3 → md:col-span-4 (vedette, 2/3)
|
||||
```
|
||||
|
||||
Sur `sm` on bascule en `grid-cols-2` classique (pas de `col-span` tablette pour garder 2 cartes par ligne), sur mobile `grid-cols-1` pleine largeur. Le même pattern est répliqué pour les skeletons de chargement → l'empreinte visuelle est stable pendant le fetch.
|
||||
|
||||
### Anatomie de carte "feuillet de vellum"
|
||||
|
||||
Toutes les cartes sont des `Link` pleine-carte (plus de `Link` imbriqué ambigu) avec :
|
||||
|
||||
- Wrapper : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, `group` pour propager le hover.
|
||||
- Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (lift subtil + empreinte tactile Stitch) remplace l'ancien `scale-105` qui débordait et cassait l'alignement de la grille.
|
||||
- Média : `aspect-[4/3]` fixe (plus de hauteurs variables) + `overflow-hidden` + `object-cover` + `group-hover:scale-[1.03]` sur l'image (sensation vitrine, discret).
|
||||
- Placeholder `material-symbols-outlined image` centré si pas d'image — `translate="no"` en place (§ 4 quinquies).
|
||||
- Corps : kicker uppercase tracking-[0.3em] (« Projet » / « Compétence ») + titre Manrope extrabold `text-primary` + description Newsreader `text-on-surface-variant` clampée à 3 lignes (`line-clamp-3`, core Tailwind 3.4) pour homogénéiser les hauteurs.
|
||||
- CTA tertiaire « Découvrir → » / « Explorer → » Manrope uppercase `text-primary`, avec flèche Material Symbols `arrow_forward` qui se décale à droite au hover (`group-hover:translate-x-1`). Icône `translate="no"`.
|
||||
|
||||
### États
|
||||
|
||||
- **Chargement** : 4 skeletons animés (`animate-pulse bg-surface-container-low/80`) suivant le même pattern de spans que la grille réelle → pas de saut de layout.
|
||||
- **Vide** : carte centrée avec Material Symbol (`inbox` pour portfolio, `school` pour compétences) + message Newsreader italique. Remplace l'ancien `text-gray-500` orphelin.
|
||||
|
||||
### Ce que ça règle
|
||||
|
||||
- **Régression mobile** : `w-80 h-96` retiré, la carte prend la largeur de la colonne → plus de débordement S25 Ultra.
|
||||
- **Bruit visuel** : `Swiper` autoplay retiré des listes, le scroll n'est plus concurrencé par 3-5 carousels qui tournent simultanément.
|
||||
- **Poids réseau** : une image `loading="lazy"` par carte au lieu de toutes les images de toutes les galeries au chargement initial.
|
||||
- **Hiérarchie** : les pages liste ont désormais un **en-tête éditorial** (kicker + titre + pitch) cohérent avec le hero de la home.
|
||||
- **Cohérence Stitch** : palette `primary` / `on-surface-variant`, radius `rounded-sheet`, ombres `shadow-ambient` / `shadow-jewel`, typographie Manrope + Newsreader → alignement 1:1 avec la home.
|
||||
|
||||
### Correctif post-étape 6 — wallpaper sur-zoomé sur pages longues
|
||||
|
||||
Retour utilisateur une fois `/portfolio` en ligne : le wallpaper apparaît **beaucoup plus zoomé** sur les listes que sur la home, ce qui casse la cohérence visuelle entre les rubriques.
|
||||
|
||||
**Cause** : dans `app/layout.tsx`, la div `.bg-wallpaper` était posée en `absolute inset-0` **à l'intérieur** du conteneur grid `min-h-[100dvh]`. Sur la home, le contenu tient en ≈ 1 viewport → le conteneur fait ≈ 1 viewport de haut → `background-size: cover` cadre l'image à sa taille naturelle. Sur les listes portfolio / compétences (en-tête + grille 4+ cartes + footer), le conteneur atteint 2 à 3 viewports de haut → `cover` redimensionne l'image pour couvrir **toute cette hauteur**, ce qui la fait apparaître zoomée et décalée. Effet amplifié au scroll car le wallpaper défile avec la page.
|
||||
|
||||
**Fix** : sortir le wallpaper du conteneur grid et le passer en `fixed inset-0 z-0 pointer-events-none`. Il est désormais calé sur le **viewport**, garde ses dimensions naturelles indépendamment de la longueur de la page, et reste stable au scroll. Les cercles animés `circle-one` / `circle-two` restent en `absolute` dans le grid pour conserver le comportement de parallax léger au scroll.
|
||||
|
||||
```tsx
|
||||
<div className="fixed inset-0 z-0 bg-wallpaper pointer-events-none" aria-hidden="true"></div>
|
||||
```
|
||||
|
||||
**Impact transversal** : corrige au passage le même problème latent sur toutes les autres pages longues (futures fiches détail, page contact si elle s'allonge, etc.) — plus besoin d'y repenser page par page.
|
||||
|
||||
### Points laissés pour l'étape 7
|
||||
|
||||
- Les composants `Carousel.tsx` et `CarouselCompetences.tsx` **ne sont pas touchés** (ils restent utilisés par les pages détail `[slug]/page.tsx`). La refonte visuelle de ces carousels (pagination, flèches, lightbox) se fera dans le lot 7 avec les fiches détail et la modale glossaire.
|
||||
- Pas de filtre / tri côté liste pour l'instant (les items sont peu nombreux, `order` de Strapi suffit). À ré-évaluer si le catalogue grossit.
|
||||
|
||||
### Correctif post-étape 6 — réintroduction du défilement automatique en vignette
|
||||
|
||||
Premier retour utilisateur après l'étape 6 : *« j'ai perdu ma fonctionnalité précédente du carousel où les images des vignettes chargées depuis Strapi défilaient »*. L'arbitrage initial *"carousel réservé aux galeries intra-fiche"* (§ 2 — tableau d'arbitrages) était motivé par le bruit visuel et le poids réseau de **plusieurs `Swiper` autoplay** qui tournaient simultanément. Mais le défilement auto des images en vignette faisait partie intégrante de l'expérience de découverte du portfolio pour l'auteur. **L'arbitrage est donc révisé** : on conserve le défilement en vignette, mais via un composant **allégé et cadré** plutôt que le `Carousel.tsx` complet.
|
||||
|
||||
**Nouveau composant `app/components/VignetteCarousel.tsx`** — différences délibérées avec `Carousel.tsx` / `CarouselCompetences.tsx` :
|
||||
|
||||
- **Pas de flèches de navigation** (`Navigation` module non chargé). Les flèches créaient une **zone de clic ambiguë** avec le `<Link>` englobant la vignette : cliquer sur une flèche déclenchait la navigation vers la fiche détail au lieu de faire défiler le carousel. L'autoplay + le swipe tactile suffisent à l'échelle d'une vignette.
|
||||
- **Pas de lightbox** (pas de `createPortal` ni de `selectedImage`). L'ouverture plein écran reste une signature de la **fiche détail**, pas de la liste.
|
||||
- **Pagination bullets Stitch** : `--swiper-pagination-color: #26445d` (primary) et bullets inactifs blancs à 55 % d'opacité, taille 6 px. Surcharge inline via `style={...}` pour éviter de polluer `globals.css` avec un sélecteur `.swiper-pagination-bullet` global qui risquerait de toucher aussi les carousels de la fiche détail.
|
||||
- **Autoplay 3500 ms** (vs 3000 ms historique) pour laisser plus de temps à la lecture sur les cartes vedette 2/3.
|
||||
- **`loop` conditionnel** (`images.length > 1`) : sans ça Swiper loggait un warning quand une entrée Strapi n'avait qu'une seule image.
|
||||
|
||||
**Intégration dans les listes** : dans `app/portfolio/page.jsx` et `app/competences/page.jsx`, la logique est `length > 1 ? <VignetteCarousel /> : <img statique />` — identique à la version pré-refonte pour les entrées mono-image, plus performante pour les entrées multi-images. Les `alt` sont générés à partir de `img.name` Strapi avec fallback sur le nom du projet / compétence.
|
||||
|
||||
**Pourquoi ne pas avoir réutilisé `Carousel.tsx` tel quel** : il embarque flèches + lightbox + CSS de navigation. Dans le contexte d'un `<Link>` englobant, les flèches auraient conflit, et la lightbox serait inaccessible (capturée par le lien). Ajouter des `stopPropagation` sur ces zones nuirait à l'UX "clic n'importe où sur la carte = ouverture de la fiche". Un composant dédié aux vignettes, avec moins de surface d'interaction, est plus clair à maintenir. Les deux composants `Carousel*.tsx` restent intacts pour la fiche détail (étape 7).
|
||||
|
||||
Les composants `app/components/Carousel.tsx` et `app/components/CarouselCompetences.tsx` deviennent donc formellement **"carousels de fiche détail"** dans la nomenclature interne ; `VignetteCarousel` est leur petit frère "liste". Un éventuel refactor plus tard pourra fusionner les deux premiers (quasi-doublons) — hors scope actuel.
|
||||
|
||||
## 5. Checklist relecture (à passer à la fin de chaque étape)
|
||||
|
||||
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
||||
|
||||
@ -8,7 +8,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
||||
|
||||
| ID | Sujet | Statut | Notes |
|
||||
|----|--------|--------|--------|
|
||||
| R1 | Moderniser l’UI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-5 (tokens + garde-fou + migration typo globale + layout racine + home) faites le 2026-04-22. |
|
||||
| R1 | Moderniser l’UI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-6 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences) faites le 2026-04-22. |
|
||||
| R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` |
|
||||
| R3 | Centraliser config API (Strapi + LLM) via `.env` | À faire | Remplacer URLs en dur où pertinent |
|
||||
| R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Évaluer extraction header/footer |
|
||||
@ -49,3 +49,6 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
||||
| 2026-04-22 | **Fix phase 1b** : font par défaut du `body` dans `app/globals.css` passée de `Arial, Helvetica, sans-serif` à `var(--font-newsreader), Georgia, serif`. Tout élément sans classe typo explicite hérite désormais de Newsreader (corps éditorial Stitch) au lieu d'Arial. **Fix phase 2 (Material Symbols)** : `@import url(...)` Material Symbols retiré de `app/globals.css` (strippé), remplacé par un triplet `<link rel="preconnect"> + <link rel="stylesheet">` injecté dans le `<head>` de `app/layout.tsx` — contourne le pipeline PostCSS tout en conservant le CDN Google pour la font-icon (usage très ponctuel : 5 icônes sur la home, 2 sur le burger, 1 sur la modale). Validé en production : icônes + fonts OK sur Chrome desktop. |
|
||||
| 2026-04-22 | **Correctif Chrome Auto-Translate mobile** (retour utilisateur : layout décalé + icônes redevenues du texte littéral sur Chrome mobile quand la traduction auto s'active). Cause : les icônes Material Symbols fonctionnent via ligatures de font (`<span>psychology</span>` → glyphe cerveau) ; Chrome traduit le texte `psychology` en `psychologie`, la ligature ne match plus, l'icône redevient texte. Fix : `translate="no"` ajouté sur les 8 `<span className="material-symbols-outlined">` (5 dans `app/page.tsx`, 2 dans `app/layout.tsx`, 1 dans `app/components/ModalGlossaire.tsx`) + sur le titre header (nom propre `Portfolio Gras-Calvet Fernand`). La traduction automatique reste globalement active pour le contenu éditorial (CV, compétences, projets) — seuls les éléments que la traduction casse sont protégés. |
|
||||
| 2026-04-22 | **Correctif séparateurs `<hr>` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `<hr>` quasi invisibles générés depuis les `---` Markdown du CV Strapi). Option B retenue : surcharge `prose-hr` sur le wrapper `ReactMarkdown` (`app/page.tsx`) pour transformer la règle en pastille Stitch centrée (`prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6`). Le séparateur redevient un signal visuel intentionnel et cohérent avec la palette, marges verticales réduites de 32 px à 24 px. Détail dans `REFONTE-VISUELLE.md` §4 sexies. |
|
||||
| 2026-04-22 | **Correctif post-étape 6 : réintroduction du défilement auto en vignette**. L'arbitrage initial "carousel retiré des listes" retiré après retour utilisateur (*« j'ai perdu ma fonctionnalité précédente »*). Nouveau composant `app/components/VignetteCarousel.tsx` : Swiper allégé (modules `Autoplay` + `Pagination` uniquement), **sans flèches** (conflit de clic avec le `<Link>` englobant) et **sans lightbox** (réservée à la fiche détail). Pagination bullets teintée `primary` via surcharge inline des variables CSS Swiper (`--swiper-pagination-color: #26445d`) pour ne pas polluer `globals.css`. Autoplay 3500 ms, `loop` conditionnel (`length > 1` pour éviter le warning Swiper sur les entrées mono-image). Intégré dans `app/portfolio/page.jsx` et `app/competences/page.jsx` via `images.length > 1 ? <VignetteCarousel /> : <img statique />` — comportement identique à l'ancien pour les mono-image, défilement retrouvé pour les multi-image. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` existants restent intacts pour la fiche détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6 *"Correctif post-étape 6 — réintroduction du défilement automatique en vignette"*. |
|
||||
| 2026-04-22 | **Correctif wallpaper sur-zoomé sur pages longues** (retour utilisateur post-étape 6 : incohérence visuelle entre la home et `/portfolio`). Cause : `.bg-wallpaper` en `absolute inset-0` à l'intérieur du conteneur grid `min-h-[100dvh]` — le conteneur s'étirant à la hauteur du contenu (≈ 2-3 viewports sur les listes), `background-size: cover` zoomait l'image pour couvrir toute cette hauteur. Fix : wallpaper sorti du grid, passé en `fixed inset-0 z-0 pointer-events-none` (calé sur le viewport, plus la page entière). Les cercles animés restent en `absolute` dans le grid. Impact transversal sur toutes les pages longues (fiches détail à venir, etc.). Détail dans `REFONTE-VISUELLE.md` §6. |
|
||||
| 2026-04-22 | Refonte visuelle — **étape 6 : listes portfolio + compétences**. `app/portfolio/page.jsx` et `app/competences/page.jsx` entièrement réécrits. En-tête éditorial (kicker + titre Manrope extrabold + pitch Newsreader) cohérent avec le hero de la home. Grille **asymétrique 2/3 + 1/3** alternée (`md:grid-cols-6` + pattern de `col-span-4`/`col-span-2` sur modulo 4, `sm:grid-cols-2`, `grid-cols-1` mobile) — conforme DESIGN.md §6 "No-Grid-Lock". Cartes « feuillet vellum » alignées home : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, image `aspect-[4/3]` fixe avec `group-hover:scale-[1.03]`, titre `text-primary`, description `line-clamp-3` en Newsreader, CTA tertiaire « Découvrir → » / « Explorer → » avec Material Symbol `arrow_forward` qui se décale au hover (`translate="no"` appliqué). Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (remplace le `scale-105` qui débordait). **`Swiper` retiré des vignettes de liste** (arbitrage acté § 2 : carousel réservé aux galeries intra-fiche) — une seule image par carte, `loading="lazy"`. États ajoutés : skeletons animés respectant la grille + état vide avec Material Symbol. Régressions corrigées au passage : largeur fixe `w-80` qui débordait sur S25 Ultra, `hover:scale-105` qui tapait sous le header, classes `bg-white/80 rounded-lg` remplacées par les tokens Stitch. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` restent en place pour les fiches détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6. |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user