mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
opti2
This commit is contained in:
parent
d423372251
commit
517eed915d
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getApiUrl } from "../../utils/getApiUrl";
|
import { getApiUrl } from "../../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage, type StrapiMediaLike } from "../../utils/strapiImage";
|
||||||
import VignetteCarousel from "../../components/VignetteCarousel";
|
import VignetteCarousel from "../../components/VignetteCarousel";
|
||||||
import ContentSectionCompetencesContainer from "../../components/ContentSectionCompetencesContainer";
|
import ContentSectionCompetencesContainer from "../../components/ContentSectionCompetencesContainer";
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ type Realisation = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
picture?: Array<{ url?: string; name?: string }>;
|
picture?: Array<StrapiMediaLike & { name?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Competence = {
|
type Competence = {
|
||||||
@ -200,10 +202,17 @@ export default function CompetencePage() {
|
|||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||||
{realisations.map((realisation, idx) => {
|
{realisations.map((realisation, idx) => {
|
||||||
const pictures = realisation.picture ?? [];
|
const pictures = realisation.picture ?? [];
|
||||||
const images = pictures.map((img) => ({
|
const images = pictures
|
||||||
url: img?.url ? `${apiUrl}${img.url}` : "/placeholder.jpg",
|
.map((img) => {
|
||||||
alt: img?.name || `Visuel de la réalisation ${realisation.name}`,
|
const picked = pickStrapiImage(apiUrl, img, "card");
|
||||||
}));
|
const url = picked?.src ?? (img?.url ? `${apiUrl}${img.url}` : null);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
alt: img?.name || `Visuel de la réalisation ${realisation.name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
const firstImage = images[0];
|
const firstImage = images[0];
|
||||||
|
|
||||||
// Comportement voulu : la vignette renvoie TOUJOURS vers la fiche
|
// Comportement voulu : la vignette renvoie TOUJOURS vers la fiche
|
||||||
@ -226,11 +235,12 @@ export default function CompetencePage() {
|
|||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<VignetteCarousel images={images} />
|
<VignetteCarousel images={images} />
|
||||||
) : firstImage ? (
|
) : firstImage ? (
|
||||||
<img
|
<Image
|
||||||
src={firstImage.url}
|
src={firstImage.url}
|
||||||
alt={firstImage.alt}
|
alt={firstImage.alt}
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
fill
|
||||||
loading="lazy"
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage } from "../utils/strapiImage";
|
||||||
import VignetteCarousel from "../components/VignetteCarousel";
|
import VignetteCarousel from "../components/VignetteCarousel";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import "../assets/main.css";
|
import "../assets/main.css";
|
||||||
@ -108,10 +110,18 @@ export default function Page() {
|
|||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||||
{competences.map((competence, idx) => {
|
{competences.map((competence, idx) => {
|
||||||
const pictures = competence.picture ?? [];
|
const pictures = competence.picture ?? [];
|
||||||
const images = pictures.map((img) => ({
|
const images = pictures
|
||||||
url: img.url ? `${apiUrl}${img.url}` : "/placeholder.jpg",
|
.map((img) => {
|
||||||
alt: img.name || `Visuel de la compétence ${competence.name}`,
|
const picked = pickStrapiImage(apiUrl, img, "card");
|
||||||
}));
|
const url =
|
||||||
|
picked?.src ?? (img.url ? `${apiUrl}${img.url}` : null);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
alt: img.name || `Visuel de la compétence ${competence.name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
const firstImage = images[0];
|
const firstImage = images[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -124,11 +134,12 @@ export default function Page() {
|
|||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<VignetteCarousel images={images} />
|
<VignetteCarousel images={images} />
|
||||||
) : firstImage ? (
|
) : firstImage ? (
|
||||||
<img
|
<Image
|
||||||
src={firstImage.url}
|
src={firstImage.url}
|
||||||
alt={firstImage.alt}
|
alt={firstImage.alt}
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
fill
|
||||||
loading="lazy"
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
@ -77,13 +78,17 @@ export default function Carousel({ images, className }: CarouselProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{images.map((img, index) => (
|
{images.map((img, index) => (
|
||||||
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
<SwiperSlide
|
||||||
<img
|
key={index}
|
||||||
|
className="relative flex h-full min-h-0 w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
fill
|
||||||
|
className="cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
||||||
|
sizes="(max-width: 768px) 100vw, min(48rem, 100vw)"
|
||||||
onClick={() => setSelectedImage(img.url)}
|
onClick={() => setSelectedImage(img.url)}
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
@ -65,13 +66,17 @@ export default function CarouselCompetences({ images, className }: CarouselProps
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{images.map((img, index) => (
|
{images.map((img, index) => (
|
||||||
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
<SwiperSlide
|
||||||
<img
|
key={index}
|
||||||
|
className="relative flex h-full min-h-0 w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
fill
|
||||||
|
className="cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
||||||
|
sizes="(max-width: 768px) 100vw, min(48rem, 100vw)"
|
||||||
onClick={() => setSelectedImage(img.url)}
|
onClick={() => setSelectedImage(img.url)}
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -4,17 +4,12 @@ import Link from "next/link";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchData } from "../utils/fetchData";
|
import { fetchData } from "../utils/fetchData";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
|
||||||
import Carousel from "./Carousel";
|
import Carousel from "./Carousel";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData extends StrapiMediaLike {
|
||||||
url: string;
|
|
||||||
formats?: {
|
|
||||||
large?: {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,10 +152,21 @@ export default function ContentSection({
|
|||||||
const richText = data.Resum ?? data.resum ?? "";
|
const richText = data.Resum ?? data.resum ?? "";
|
||||||
|
|
||||||
const images =
|
const images =
|
||||||
picture?.map((img: ImageData) => ({
|
picture
|
||||||
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
?.map((img: ImageData) => {
|
||||||
alt: img.name || `Visuel du projet ${name}`,
|
const picked = pickStrapiImage(apiUrl, img, "full");
|
||||||
})) || [];
|
const url =
|
||||||
|
picked?.src ??
|
||||||
|
(img.url || img.formats?.large?.url
|
||||||
|
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
|
||||||
|
: null);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
alt: img.name || `Visuel du projet ${name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { url: string; alt: string } => item != null) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
|
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
|
||||||
|
|||||||
@ -3,16 +3,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
|
||||||
import CarouselCompetences from "./CarouselCompetences";
|
import CarouselCompetences from "./CarouselCompetences";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
import ModalGlossaire from "./ModalGlossaire";
|
import ModalGlossaire from "./ModalGlossaire";
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData extends StrapiMediaLike {
|
||||||
url: string;
|
|
||||||
formats?: {
|
|
||||||
large?: { url: string };
|
|
||||||
};
|
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,10 +129,21 @@ export default function ContentSectionCompetences({
|
|||||||
const { name, content, picture } = competenceData;
|
const { name, content, picture } = competenceData;
|
||||||
|
|
||||||
const images =
|
const images =
|
||||||
picture?.map((img) => ({
|
picture
|
||||||
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
?.map((img: ImageData) => {
|
||||||
alt: img.name || `Visuel de la compétence ${name}`,
|
const picked = pickStrapiImage(apiUrl, img, "full");
|
||||||
})) || [];
|
const url =
|
||||||
|
picked?.src ??
|
||||||
|
(img.url || img.formats?.large?.url
|
||||||
|
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
|
||||||
|
: null);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
alt: img.name || `Visuel de la compétence ${name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { url: string; alt: string } => item != null) || [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword`
|
* Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword`
|
||||||
|
|||||||
@ -4,15 +4,10 @@ import { useEffect } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import CarouselCompetences from "./CarouselCompetences";
|
import CarouselCompetences from "./CarouselCompetences";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData extends StrapiMediaLike {
|
||||||
url: string;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
formats?: {
|
|
||||||
large?: {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GlossaireMot {
|
interface GlossaireMot {
|
||||||
@ -43,10 +38,21 @@ export default function ModalGlossaire({ mot, onClose }: ModalGlossaireProps) {
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const images =
|
const images =
|
||||||
mot.images?.map((img) => ({
|
mot.images
|
||||||
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
?.map((img: ImageData) => {
|
||||||
alt: img.name || "Illustration",
|
const picked = pickStrapiImage(apiUrl, img, "full");
|
||||||
})) || [];
|
const url =
|
||||||
|
picked?.src ??
|
||||||
|
(img.url || img.formats?.large?.url
|
||||||
|
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
|
||||||
|
: null);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
alt: img.name || "Illustration",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { url: string; alt: string } => item != null) || [];
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import { Autoplay, Pagination } from "swiper/modules";
|
import { Autoplay, Pagination } from "swiper/modules";
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
@ -54,12 +55,16 @@ export default function VignetteCarousel({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{images.map((img, index) => (
|
{images.map((img, index) => (
|
||||||
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
<SwiperSlide
|
||||||
<img
|
key={index}
|
||||||
|
className="relative flex h-full min-h-0 w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="h-full w-full object-cover"
|
fill
|
||||||
loading="lazy"
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -89,6 +89,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
href="https://fonts.gstatic.com"
|
href="https://fonts.gstatic.com"
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
|
{/* API Strapi (médias JSON + images) — origine alignée sur NEXT_PUBLIC_API_URL. */}
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href={process.env.NEXT_PUBLIC_API_URL || "https://api.fernandgrascalvet.com"}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||||
|
|||||||
18
app/page.tsx
18
app/page.tsx
@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import "./assets/main.css";
|
import "./assets/main.css";
|
||||||
import { getApiUrl } from "./utils/getApiUrl";
|
import { getApiUrl } from "./utils/getApiUrl";
|
||||||
|
import { pickStrapiImage } from "./utils/strapiImage";
|
||||||
|
|
||||||
async function getHomepageData() {
|
async function getHomepageData() {
|
||||||
const apiUrl = getApiUrl();
|
const apiUrl = getApiUrl();
|
||||||
@ -106,7 +108,12 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const title = homepage.title ?? "Titre par défaut";
|
const title = homepage.title ?? "Titre par défaut";
|
||||||
const cv: string = homepage.cv ?? "";
|
const cv: string = homepage.cv ?? "";
|
||||||
const imageUrl = homepage.photo?.url ? `${apiUrl}${homepage.photo.url}` : null;
|
const portraitPick = homepage.photo
|
||||||
|
? pickStrapiImage(apiUrl, homepage.photo, "hero")
|
||||||
|
: null;
|
||||||
|
const imageUrl =
|
||||||
|
portraitPick?.src ??
|
||||||
|
(homepage.photo?.url ? `${apiUrl}${homepage.photo.url}` : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-3 px-4 pb-10 sm:px-6">
|
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-3 px-4 pb-10 sm:px-6">
|
||||||
@ -120,11 +127,14 @@ export default function HomePage() {
|
|||||||
<div className="mx-auto md:mx-0">
|
<div className="mx-auto md:mx-0">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<div className="rounded-sheet bg-primary p-1 shadow-ambient-sm">
|
<div className="rounded-sheet bg-primary p-1 shadow-ambient-sm">
|
||||||
<div className="overflow-hidden rounded-[1.25rem]">
|
<div className="relative h-48 w-48 overflow-hidden rounded-[1.25rem] sm:h-56 sm:w-56 md:h-64 md:w-64">
|
||||||
<img
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={`Portrait de ${title}`}
|
alt={`Portrait de ${title}`}
|
||||||
className="h-48 w-48 object-cover object-center sm:h-56 sm:w-56 md:h-64 md:w-64"
|
fill
|
||||||
|
className="object-cover object-center"
|
||||||
|
sizes="(max-width: 768px) 12rem, 16rem"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
|
import { pickStrapiImage } from "../utils/strapiImage";
|
||||||
import VignetteCarousel from "../components/VignetteCarousel";
|
import VignetteCarousel from "../components/VignetteCarousel";
|
||||||
import "../assets/main.css";
|
import "../assets/main.css";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
@ -113,10 +115,16 @@ export default function Page() {
|
|||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
|
||||||
{projects.map((project, idx) => {
|
{projects.map((project, idx) => {
|
||||||
const pictures = project.picture ?? [];
|
const pictures = project.picture ?? [];
|
||||||
const images = pictures.map((img) => ({
|
const images = pictures
|
||||||
url: `${apiUrl}${img.url}`,
|
.map((img) => {
|
||||||
alt: img.name || `Visuel du projet ${project.name}`,
|
const picked = pickStrapiImage(apiUrl, img, "card");
|
||||||
}));
|
if (!picked) return null;
|
||||||
|
return {
|
||||||
|
url: picked.src,
|
||||||
|
alt: img.name || `Visuel du projet ${project.name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
const firstImage = images[0];
|
const firstImage = images[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -129,11 +137,12 @@ export default function Page() {
|
|||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<VignetteCarousel images={images} />
|
<VignetteCarousel images={images} />
|
||||||
) : firstImage ? (
|
) : firstImage ? (
|
||||||
<img
|
<Image
|
||||||
src={firstImage.url}
|
src={firstImage.url}
|
||||||
alt={firstImage.alt}
|
alt={firstImage.alt}
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
fill
|
||||||
loading="lazy"
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
|
||||||
|
|||||||
69
app/utils/strapiImage.ts
Normal file
69
app/utils/strapiImage.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Sélection d'une variante Strapi (thumbnail / small / medium / large) pour
|
||||||
|
* limiter le poids transféré sans sacrifier la qualité affichée.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StrapiImagePreset = "card" | "thumbnail" | "hero" | "full";
|
||||||
|
|
||||||
|
export interface StrapiFormatEntry {
|
||||||
|
url?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrapiMediaLike {
|
||||||
|
url?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
formats?: {
|
||||||
|
thumbnail?: StrapiFormatEntry | null;
|
||||||
|
small?: StrapiFormatEntry | null;
|
||||||
|
medium?: StrapiFormatEntry | null;
|
||||||
|
large?: StrapiFormatEntry | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absUrl(apiUrl: string, path: string | null | undefined): string | null {
|
||||||
|
if (path == null || path === "") return null;
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||||
|
return `${apiUrl.replace(/\/$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choisit l’URL + dimensions d’une image média Strapi selon le contexte d’affichage.
|
||||||
|
* Retourne null si aucune URL exploitable.
|
||||||
|
*/
|
||||||
|
export function pickStrapiImage(
|
||||||
|
apiUrl: string,
|
||||||
|
media: StrapiMediaLike | null | undefined,
|
||||||
|
preset: StrapiImagePreset
|
||||||
|
): { src: string; width: number; height: number } | null {
|
||||||
|
if (!media?.url) return null;
|
||||||
|
|
||||||
|
const f = media.formats;
|
||||||
|
let chosen: StrapiFormatEntry | undefined;
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "thumbnail":
|
||||||
|
chosen = f?.thumbnail ?? f?.small ?? undefined;
|
||||||
|
break;
|
||||||
|
case "card":
|
||||||
|
chosen = f?.medium ?? f?.small ?? f?.thumbnail ?? undefined;
|
||||||
|
break;
|
||||||
|
case "hero":
|
||||||
|
case "full":
|
||||||
|
chosen = f?.large ?? f?.medium ?? f?.small ?? undefined;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
chosen = f?.medium ?? f?.small;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPath = chosen?.url ?? media.url;
|
||||||
|
const src = absUrl(apiUrl, relPath);
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
const width = Number(chosen?.width ?? media.width) || 800;
|
||||||
|
const height = Number(chosen?.height ?? media.height) || 600;
|
||||||
|
|
||||||
|
return { src, width, height };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Frontend Next.js
|
# Frontend Next.js
|
||||||
|
|
||||||
**Dernière mise à jour :** 2026-04-24
|
**Dernière mise à jour :** 2026-04-28
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@ -43,7 +43,9 @@
|
|||||||
## Configuration Next
|
## Configuration Next
|
||||||
|
|
||||||
- `next.config.ts` : `rewrites` de `/api/:path*` vers `${API_URL}/api/:path*` où `API_URL` vient de `NEXT_PUBLIC_API_URL` ou défaut production.
|
- `next.config.ts` : `rewrites` de `/api/:path*` vers `${API_URL}/api/:path*` où `API_URL` vient de `NEXT_PUBLIC_API_URL` ou défaut production.
|
||||||
- `images.domains` : `localhost`, `api.fernandgrascalvet.com`.
|
- **`next/image`** : `images.remotePatterns` vers les chemins **`/uploads/**`** de l’API Strapi (HTTPS prod + `localhost` / `127.0.0.1:1337` en dev) ; `formats` AVIF/WebP ; **`compress: false`** (compatibilité reverse proxy IIS — voir [`09-performances-images.md`](./09-performances-images.md) §4.1).
|
||||||
|
- **Médias Strapi** : utilitaire **`pickStrapiImage`** (`app/utils/strapiImage.ts`) pour préférer les variantes `medium` / `large` selon le contexte (liste, hero, galerie).
|
||||||
|
- Plan **Server Components** (données Strapi), non implémenté : [`10-plan-server-components.md`](./10-plan-server-components.md).
|
||||||
|
|
||||||
## Composants notables
|
## Composants notables
|
||||||
|
|
||||||
@ -59,6 +61,7 @@
|
|||||||
app/layout.tsx
|
app/layout.tsx
|
||||||
app/page.tsx
|
app/page.tsx
|
||||||
app/utils/getApiUrl.ts
|
app/utils/getApiUrl.ts
|
||||||
|
app/utils/strapiImage.ts
|
||||||
app/utils/fetchData.ts
|
app/utils/fetchData.ts
|
||||||
app/api/contact/route.ts
|
app/api/contact/route.ts
|
||||||
next.config.ts
|
next.config.ts
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
# Audit performances images & dev mode
|
# Audit performances images & dev mode
|
||||||
|
|
||||||
**Dernière mise à jour :** 2026-04-28
|
**Dernière mise à jour :** 2026-04-28 (révision compression IIS/Next + lien plan SC)
|
||||||
**Statut :** diagnostic — aucune modification du code n'est encore appliquée.
|
**Statut :** lots **A, B, C** et socle du lot **F** réalisés dans le code Next ; inventaire médias (Lot **D**) et autres lots **non** faits sauf mention.
|
||||||
|
|
||||||
> Document d'analyse à l'origine d'un futur lot de corrections. À mettre à jour
|
> Document hybridé : conserve l’audit historique (§2 inventaire médias inchangé
|
||||||
> au fur et à mesure que les actions sont réalisées (cocher les cases).
|
> tant qu’on n’a pas re-mesuré `cmsbackend/public/uploads/`). Les §3–4 reflètent
|
||||||
|
> l’implémentation actuelle après l’itération perf front.
|
||||||
|
|
||||||
## 1. Contexte du problème
|
## 1. Contexte du problème
|
||||||
|
|
||||||
@ -59,66 +60,63 @@ Strapi génère bien ces variantes — mais **dans le format de l'original**. Do
|
|||||||
un `.png` de 4 MB produit un `large_…png` de ~1 MB, alors qu'une version
|
un `.png` de 4 MB produit un `large_…png` de ~1 MB, alors qu'une version
|
||||||
WebP serait à ~150-300 KB pour la même qualité perçue.
|
WebP serait à ~150-300 KB pour la même qualité perçue.
|
||||||
|
|
||||||
## 3. Comment les images sont consommées par Next
|
## 3. Comment les images sont consommées par Next (état courant)
|
||||||
|
|
||||||
### 3.1 Toujours `img.url` (l'original), jamais les variantes
|
### 3.1 Utilitaire `pickStrapiImage` (`app/utils/strapiImage.ts`)
|
||||||
|
|
||||||
`app/portfolio/page.jsx` (vignettes en grille) :
|
Toute lecture d’un média Strapi passant par le front doit préférer une **variante**
|
||||||
|
à l’original :
|
||||||
|
|
||||||
```132:137:app/portfolio/page.jsx
|
| Preset | Usage typique | Ordre de préférence |
|
||||||
<img
|
|--------|----------------|---------------------|
|
||||||
src={firstImage.url}
|
| `card` | Grilles liste portfolio / compétences / vignettes `realisation-ia` | `medium` → `small` → `thumbnail` → original |
|
||||||
alt={firstImage.alt}
|
| `hero` | Portrait hero home | `large` → `medium` → `small` → original |
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
| `full` | Galeries fiche projet, compétence, glossaire (carousel détail) | `large` → `medium` → `small` → original |
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Même pattern dans :
|
Les pages et composants listés au §3.2 appellent cet utilitaire puis
|
||||||
|
construisent une URL absolue avec `getApiUrl()`. Si Strapi ne renvoie pas de
|
||||||
|
bloc `formats` (upload incomplet ou vieux contenu), on retombe sur
|
||||||
|
`formats.large ?? url` comme avant pour les zones **déjà** codées ainsi.
|
||||||
|
|
||||||
- `app/competences/page.jsx` (ligne ~127)
|
### 3.2 `next/image` + `sizes`
|
||||||
- `app/competences/[slug]/page.tsx` (ligne ~229)
|
|
||||||
- `app/components/Carousel.tsx` (ligne ~81 + lightbox 120)
|
|
||||||
- `app/components/CarouselCompetences.tsx` (ligne ~69 + lightbox 108)
|
|
||||||
- `app/components/VignetteCarousel.tsx` (ligne ~58)
|
|
||||||
- `app/page.tsx` (portrait hero, ligne 124)
|
|
||||||
|
|
||||||
**Aucun de ces composants ne lit `formats.thumbnail.url`, `formats.small.url`,
|
**Import** `next/image` utilisé pour les flux suivants :
|
||||||
`formats.medium.url` ni `formats.large.url`** — Strapi a déjà fait le travail
|
|
||||||
de redimensionnement, on l'ignore.
|
|
||||||
|
|
||||||
> **Constat n° 2 :** une carte de portfolio en grille charge un PNG ~3 MB
|
- **`app/page.tsx`** — portrait hero : `fill` dans un bloc dimensionné +
|
||||||
> alors qu'on l'affiche en 400×300 px. La variante `medium_` (~300 KB) ou
|
**`priority`** (LCP).
|
||||||
> `small_` (~120 KB) suffirait — gain immédiat de **~10× sur le poids
|
- **`app/portfolio/page.jsx`**, **`app/competences/page.jsx`**,
|
||||||
> transféré**, sans toucher aux fichiers stockés.
|
**`app/competences/[slug]/page.tsx`** — vignettes : `fill` +
|
||||||
|
`sizes` adaptatif.
|
||||||
|
- **`VignetteCarousel.tsx`**, **`Carousel.tsx`**, **`CarouselCompetences.tsx`**
|
||||||
|
— slides Swiper en `fill` avec `sizes` ; la **lightbox** reste une **`<img>`**
|
||||||
|
native (zoom plein cadre sans contraintes de dimensions Next).
|
||||||
|
|
||||||
### 3.2 `<img>` natif partout — `next/image` jamais utilisé
|
**Non couvert dans ce lot** : `placeholder="blur"` avec `blurDataURL` dérivé
|
||||||
|
de `formats.thumbnail` (restait dans le périmètre « idéal » du lot F
|
||||||
|
historique).
|
||||||
|
|
||||||
`grep` confirme : **aucun `import Image from "next/image"`** dans tout `app/`.
|
### 3.3 Configuration `next.config.ts`
|
||||||
Conséquence :
|
|
||||||
|
|
||||||
- pas de `srcset`/`sizes` automatique (le navigateur télécharge la même image
|
- **`images.remotePatterns`** vers `uploads/**` pour
|
||||||
en mobile qu'en desktop) ;
|
**`https://api.fernandgrascalvet.com`**, **`http://localhost:1337`** et
|
||||||
- pas de placeholder `blur` ;
|
**`http://127.0.0.1:1337`** (aligné sur `getApiUrl()` en dev).
|
||||||
- pas de conversion à la volée vers AVIF/WebP via le runtime image de Next ;
|
- **`images.formats`** : `image/avif`, `image/webp`.
|
||||||
- aucune dimension donnée (`width`/`height`) → CLS (Cumulative Layout Shift)
|
- Ancienne clé **`images.domains`** : retirée (dépréciée depuis Next 14).
|
||||||
potentiel pendant le chargement.
|
|
||||||
|
|
||||||
`next.config.ts` autorise pourtant `localhost` et `api.fernandgrascalvet.com`
|
Référence code : fichier `next.config.ts` à la racine du dépôt Next.
|
||||||
dans `images.domains` — la migration est donc partiellement amorcée mais
|
|
||||||
inutilisée :
|
|
||||||
|
|
||||||
```24:26:next.config.ts
|
---
|
||||||
images: {
|
|
||||||
domains: ["localhost", "api.fernandgrascalvet.com"],
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Note : `domains` est **déprécié** depuis Next 14 ; il faudra basculer vers
|
### Ancien diagnostic (archivé pour mémoire)
|
||||||
`remotePatterns` au moment de la migration (et préciser
|
|
||||||
`formats: ['image/avif', 'image/webp']`).
|
|
||||||
|
|
||||||
### 3.3 Toutes les pages sont `"use client"` + `useEffect`-fetch
|
Avant cette itération, les listes chargeaient surtout **`img.url`** (original)
|
||||||
|
sans variantes Strapi ni `next/image`. Les métriques de l’inventaire §2 restent
|
||||||
|
utiles tant que les **originaux** côté disque sont lourds (PNG) : même avec des
|
||||||
|
variantes bien choisies, Strapi régénère souvent ces variantes **dans le même
|
||||||
|
format** que l’original — une conversion fichier (Lot **E**) reste pertinente
|
||||||
|
pour réduire le stockage total.
|
||||||
|
|
||||||
|
### 3.4 Toutes les pages sont `"use client"` + `useEffect`-fetch
|
||||||
|
|
||||||
`app/page.tsx`, `app/portfolio/page.jsx`, `app/competences/page.jsx`,
|
`app/page.tsx`, `app/portfolio/page.jsx`, `app/competences/page.jsx`,
|
||||||
`app/competences/[slug]/page.tsx` : toutes commencent par `"use client"` et
|
`app/competences/[slug]/page.tsx` : toutes commencent par `"use client"` et
|
||||||
@ -135,26 +133,40 @@ font `fetch()` côté navigateur dans `useEffect`. Conséquences :
|
|||||||
> **Constat n° 3 :** migrer ces pages en **Server Components** (fetch dans le
|
> **Constat n° 3 :** migrer ces pages en **Server Components** (fetch dans le
|
||||||
> composant async + `revalidate: 60`) résoudrait à la fois le ressenti de
|
> composant async + `revalidate: 60`) résoudrait à la fois le ressenti de
|
||||||
> lenteur **et** la latence images, puisque les `<img>` seraient dans le HTML
|
> lenteur **et** la latence images, puisque les `<img>` seraient dans le HTML
|
||||||
> initial — le navigateur lance les requêtes images en parallèle du JS.
|
> initial — le navigateur lance les requêtes images en parallèle du JS. Plan détaillé :
|
||||||
|
> [`10-plan-server-components.md`](./10-plan-server-components.md).
|
||||||
|
|
||||||
## 4. Mode `dev` : impact réel
|
## 4. Mode `dev` : impact réel
|
||||||
|
|
||||||
L'utilisateur souhaite **rester en dev pour le moment** — c'est noté. Voici
|
L'utilisateur souhaite **rester en dev pour le moment** — c'est noté. Voici
|
||||||
ce que ça coûte vraiment, du plus marquant au moins marquant.
|
ce que ça coûte vraiment, du plus marquant au moins marquant.
|
||||||
|
|
||||||
### 4.1 Compression HTTP désactivée explicitement (Next)
|
### 4.1 Compression HTTP (Next) et reverse proxy IIS
|
||||||
|
|
||||||
```9:12:next.config.ts
|
**Décision effective (diagnostic avril 2026, exposition HTTPS derrière IIS) :** **`compress: false`**
|
||||||
const nextConfig = {
|
dans `next.config.ts` (état actuel du dépôt).
|
||||||
reactStrictMode: true,
|
|
||||||
compress: false,
|
|
||||||
trailingSlash: false,
|
|
||||||
```
|
|
||||||
|
|
||||||
`compress: false` désactive **gzip/brotli** côté Next — y compris en
|
- Un passage à **`compress: true`** a provoqué des **HTTP 500** côté
|
||||||
production. Pour du JSON Strapi de 50 KB ou un bundle JS de 500 KB, c'est un
|
**navigateur** alors que Next loguait **`GET / 200`** : la réponse **gzip**
|
||||||
facteur 4 à 8 sur le poids transféré. **À retirer ou passer à `true` même
|
générée par Next n'était **pas** correctement gérée par la chaîne
|
||||||
en dev.**
|
**IIS + URL Rewrite + ARR** vers `http://localhost:3000` (buffer / en-têtes /
|
||||||
|
double traitement).
|
||||||
|
- La **compression dynamique IIS**, une fois son module installé, **n’a pas été
|
||||||
|
retenue** comme substitution fiable sur ce périmètre non plus tant que la combinaison
|
||||||
|
tunnel + Next n’a pas été retestée de façon isolée.
|
||||||
|
|
||||||
|
> Tant que le site est exposé **derrière IIS de cette façon**, ne pas réactiver
|
||||||
|
> **`compress: true`** côté Next sans **test** sur **`https://fernandgrascalvet.com`**.
|
||||||
|
> Le léger surplus de transfert brut **localhost ⇄ IIS** est acceptable en dev/serveur.
|
||||||
|
|
||||||
|
### 4.1b IIS — compression (référence)
|
||||||
|
|
||||||
|
- **Compression statique** : peut rester active sur les sites IIS (fichiers
|
||||||
|
servis directement par IIS) ; hors scope du corps HTML proxifié vers Next.
|
||||||
|
- **Compression dynamique** : module séparé (rôle serveur Web) ; **ne remplace pas**
|
||||||
|
le problème **`Content-Encoding`** venant de Next si un jour on réactive gzip côté app.
|
||||||
|
|
||||||
|
Le mode `next dev` reste sans minification agressive du JS (voir §4.2).
|
||||||
|
|
||||||
### 4.2 `next dev --turbopack`
|
### 4.2 `next dev --turbopack`
|
||||||
|
|
||||||
@ -195,9 +207,11 @@ invalidant tout. **Voir `cmsbackend/config/middlewares.ts`** :
|
|||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Constat n° 4 :** en dev, l'impact « gros » est `compress: false`. Le
|
> **Constat n° 4 (révisé encore) :** on garde **`compress: false`** côté Next
|
||||||
> reste du dev mode est inconfortable au démarrage mais n'explique pas la
|
> pour éviter les **500 IIS** derrière reverse proxy ; le surplus de transfert HTML/JSON non gzip
|
||||||
> lenteur image perçue.
|
> entre IIS et localhost est préféré à une page publique cassée.
|
||||||
|
> Le reste du mode `dev` reste plus lourd qu’une **build prod** (bundle JS, pas de data cache
|
||||||
|
> Next sur les pages entièrement client).
|
||||||
|
|
||||||
## 5. Autres pistes détectées en cours d'audit
|
## 5. Autres pistes détectées en cours d'audit
|
||||||
|
|
||||||
@ -216,19 +230,19 @@ probablement morts** mais alourdissent : `git clone`, sauvegardes, indexation
|
|||||||
IDE, et potentiellement `next build` s'ils sont importés depuis un fichier
|
IDE, et potentiellement `next build` s'ils sont importés depuis un fichier
|
||||||
encore référencé. À auditer (`grep` sur les imports) puis purger ou archiver.
|
encore référencé. À auditer (`grep` sur les imports) puis purger ou archiver.
|
||||||
|
|
||||||
### 5.2 Pas de `link rel="preload"` pour le portrait hero
|
### 5.2 Portrait hero et LCP
|
||||||
|
|
||||||
`app/page.tsx` (home) affiche un portrait via `<img>` après fetch
|
**Partiellement traité (2026-04-28) :** le portrait utilise **`next/image`**
|
||||||
client-side. Sur la home, c'est l'image principale au-dessus de la ligne de
|
avec **`priority`** dans `app/page.tsx` (pas de `rel=preload` séparé). Le fetch
|
||||||
flottaison ; un preload (ou un Server Component + `next/image priority`)
|
home reste **client** (`useEffect`) — le gain LCP complet viendra surtout avec
|
||||||
ferait gagner ~200-500 ms de TTI sur le LCP.
|
le lot **G** (Server Components).
|
||||||
|
|
||||||
### 5.3 Pas de hint réseau vers `api.fernandgrascalvet.com`
|
### 5.3 Preconnect vers l’API Strapi
|
||||||
|
|
||||||
Aucun `<link rel="preconnect" href="https://api.fernandgrascalvet.com">`
|
**Fait (2026-04-28) :** `app/layout.tsx` injecte
|
||||||
dans `app/layout.tsx`. Le premier round-trip image en prod paye le DNS +
|
`<link rel="preconnect" href={process.env.NEXT_PUBLIC_API_URL || URL prod par défaut} crossOrigin="" />`
|
||||||
TLS handshake en série ; un preconnect le déclenche pendant le parsing
|
pour l’origine API (médias + JSON). En local, si `.env` pointe vers
|
||||||
HTML.
|
`http://localhost:1337`, le preconnect cible ce host.
|
||||||
|
|
||||||
### 5.4 Wallpaper OK, alternatives mortes
|
### 5.4 Wallpaper OK, alternatives mortes
|
||||||
|
|
||||||
@ -242,20 +256,16 @@ Tri par ratio gain / effort. À discuter avant exécution.
|
|||||||
|
|
||||||
### Quick wins (≤ 1 h, gros gains)
|
### Quick wins (≤ 1 h, gros gains)
|
||||||
|
|
||||||
- [ ] **Lot A — Lire les variantes Strapi côté Next.**
|
- [x] **Lot A — Lire les variantes Strapi côté Next.**
|
||||||
Modifier les 6 emplacements `<img src={img.url}>` pour préférer
|
Implémenté via **`pickStrapiImage`** (`app/utils/strapiImage.ts`) et branchements
|
||||||
`img.formats?.medium?.url ?? img.formats?.small?.url ?? img.url`.
|
listes + carousels + fiches (voir §3). **À re-mesurer** sur `/portfolio` et
|
||||||
Ajouter une fonction utilitaire `pickStrapiImage(picture, "card" | "thumbnail" | "full")`
|
`/competences` (Network → Img) après redémarrage des services.
|
||||||
dans `app/utils/`. **Gain attendu :** ÷ 5 à ÷ 10 sur le poids des grilles
|
- [ ] **Lot B — Compression Next (`compress`) — annulé dans cette forme.**
|
||||||
de portfolio/compétences. Aucune perte qualité (les variantes sont
|
Tentative **`compress: true`** puis **rétablissement à `false`** : conflit avec
|
||||||
dimensionnées par Strapi à partir des originaux).
|
reverse proxy IIS (**500** public). Voir §4.1.
|
||||||
- [ ] **Lot B — Activer la compression Next.**
|
- [x] **Lot C — `preconnect` API Strapi.**
|
||||||
Passer `compress: false` → `compress: true` dans `next.config.ts`.
|
Lien dans `app/layout.tsx`, origine pilotée par **`NEXT_PUBLIC_API_URL`**
|
||||||
**Gain attendu :** ÷ 4 sur le JSON Strapi et le HTML.
|
avec repli sur l’API de production.
|
||||||
- [ ] **Lot C — `preconnect` API Strapi.**
|
|
||||||
Ajouter `<link rel="preconnect" href="https://api.fernandgrascalvet.com" crossOrigin="" />`
|
|
||||||
dans `app/layout.tsx`. **Gain attendu :** ~150-300 ms sur le TTFB des
|
|
||||||
images en prod, négligeable en local.
|
|
||||||
|
|
||||||
### Lots moyens (1-3 h chacun)
|
### Lots moyens (1-3 h chacun)
|
||||||
|
|
||||||
@ -284,12 +294,10 @@ Tri par ratio gain / effort. À discuter avant exécution.
|
|||||||
**Réversibilité :** le script garde les originaux dans `extract/backup/`
|
**Réversibilité :** le script garde les originaux dans `extract/backup/`
|
||||||
jusqu'à validation manuelle.
|
jusqu'à validation manuelle.
|
||||||
|
|
||||||
- [ ] **Lot F — Migration `<img>` → `next/image`.**
|
- [x] **Lot F — Migration `<img>` → `next/image` (socle).**
|
||||||
Remplacer les 6 occurrences. Mettre à jour `next.config.ts` :
|
Fait pour les flux principaux (listes, carousels, hero) + **`remotePatterns`**
|
||||||
`domains` → `remotePatterns`, ajouter
|
+ **`formats` AVIF/WebP**. **Non fait :** `deviceSizes` explicites (défaut
|
||||||
`formats: ['image/avif', 'image/webp']`, définir `deviceSizes` cohérents
|
Next), **`placeholder="blur"`** — laissé en dette optionnelle.
|
||||||
avec les breakpoints Tailwind. Ajouter `priority` au portrait hero,
|
|
||||||
`placeholder="blur"` (avec `blurDataURL` provenant de `formats.thumbnail`).
|
|
||||||
|
|
||||||
### Lots structurels (½ journée +)
|
### Lots structurels (½ journée +)
|
||||||
|
|
||||||
@ -298,6 +306,7 @@ Tri par ratio gain / effort. À discuter avant exécution.
|
|||||||
vers un sous-composant client uniquement pour l'interactivité (carousels,
|
vers un sous-composant client uniquement pour l'interactivité (carousels,
|
||||||
modal). Bénéfices : HTML initial complet, fetch caché serveur, pas de
|
modal). Bénéfices : HTML initial complet, fetch caché serveur, pas de
|
||||||
spinner au premier paint. Compatible avec Lot F (`next/image priority`).
|
spinner au premier paint. Compatible avec Lot F (`next/image priority`).
|
||||||
|
**Plan rédigé :** [`10-plan-server-components.md`](./10-plan-server-components.md).
|
||||||
|
|
||||||
- [ ] **Lot H — Plugin Strapi pour conversion WebP à l'upload.**
|
- [ ] **Lot H — Plugin Strapi pour conversion WebP à l'upload.**
|
||||||
Configurer `@strapi/provider-upload-local` (ou un plugin custom) pour
|
Configurer `@strapi/provider-upload-local` (ou un plugin custom) pour
|
||||||
@ -322,18 +331,26 @@ Tri par ratio gain / effort. À discuter avant exécution.
|
|||||||
Stocker les captures dans `docs-site-interne/captures/perf/` avec le
|
Stocker les captures dans `docs-site-interne/captures/perf/` avec le
|
||||||
nom `<lot>-<avant|apres>.webp`.
|
nom `<lot>-<avant|apres>.webp`.
|
||||||
|
|
||||||
## 8. Ce que ce document **ne** fait pas (encore)
|
## 8. Suite / dettes
|
||||||
|
|
||||||
- Aucune ligne de code modifiée — c'est un audit.
|
- **Contenu Strapi** : harmonisation des médias et WebP côté CMS faite par l’auteur
|
||||||
- Le script `strapi_extraction/audit-images.js` (Lot D) reste à écrire.
|
— l’inventaire §2 n’a pas été **re-mesuré** ; relancer un passage sur
|
||||||
- Les conversions WebP (Lot E) restent à faire.
|
`cmsbackend/public/uploads/` si besoin de chiffres à jour.
|
||||||
|
- **Lot D** — script `strapi_extraction/audit-images.js` : **pas écrit**.
|
||||||
|
- **Lot E** — conversion + ré-upload : **hors code Next** ; voir pipeline
|
||||||
|
`strapi_extraction/media-sync` si utilisé pour le remplacement ciblé.
|
||||||
|
- **Lot G** — Server Components sur `/`, `/portfolio`, `/competences` : **à faire**
|
||||||
|
(voir [`10-plan-server-components.md`](./10-plan-server-components.md)).
|
||||||
|
- **Lots H / I** : inchangés (plugin Strapi upload, purge `app/assets/images/`).
|
||||||
|
|
||||||
À la prochaine itération : créer le script d'audit images en s'inspirant
|
Prochaine amélioration doc utile : captures **avant/après** réseau dans
|
||||||
de la structure de `strapi_extraction/extract-api-data.js` (même style de
|
`docs-site-interne/captures/perf/` une fois les services redémarrés et le
|
||||||
log, même répertoire `extract/`, même résumé JSON final).
|
parcours manuel validé.
|
||||||
|
|
||||||
## 9. Liens internes
|
## 9. Liens internes
|
||||||
|
|
||||||
|
- Migration Server Components (plan) :
|
||||||
|
[`10-plan-server-components.md`](./10-plan-server-components.md)
|
||||||
- Pipeline d'extraction Strapi existant :
|
- Pipeline d'extraction Strapi existant :
|
||||||
[`06-strapi-extraction.md`](./06-strapi-extraction.md)
|
[`06-strapi-extraction.md`](./06-strapi-extraction.md)
|
||||||
- Architecture globale & ports :
|
- Architecture globale & ports :
|
||||||
|
|||||||
116
docs-site-interne/10-plan-server-components.md
Normal file
116
docs-site-interne/10-plan-server-components.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Plan — migration vers Server Components (données Strapi)
|
||||||
|
|
||||||
|
**Dernière mise à jour :** 2026-04-28
|
||||||
|
**Statut :** document de planification — **aucun changement de code appliqué** dans ce lot ; mise en œuvre quand vous le déciderez.
|
||||||
|
|
||||||
|
**Prérequis lu :** vous restez en **`npm run dev`** (Turbopack) pour le moment ; ce document décrit aussi ce que donne **`next build` / `next start`** en complément.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Réduire le pattern actuel : pages marquées **`"use client"`** qui appellent **`fetch()` dans `useEffect`** après hydration.
|
||||||
|
|
||||||
|
Vers : **`async`** page (ou composant serveur parent) qui appelle **`fetch`** **côté serveur Next**, avec mise en **`cache`/revalidation** possible, puis rendu HTML **déjà rempli** de contenu texte + balises images.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. État actuel (référence doc + code)
|
||||||
|
|
||||||
|
| Page / zone | Fichier | Pattern actuel |
|
||||||
|
|-------------|---------|----------------|
|
||||||
|
| Accueil | `app/page.tsx` | `"use client"` + `useEffect` → `getHomepageData()` |
|
||||||
|
| Liste portfolio | `app/portfolio/page.jsx` | idem → `/api/projects` |
|
||||||
|
| Liste compétences | `app/competences/page.jsx` | idem → `/api/competences` |
|
||||||
|
| Compétence (vignettes réalisations) | `app/competences/[slug]/page.tsx` | idem, fetchs multiples |
|
||||||
|
|
||||||
|
Conséquences documentées dans [`09-performances-images.md`](./09-performances-images.md) §3.4 : premier paint sans données, pas de data cache Next sur ces flux, images découvertes tard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ce que « Server Components » implique (concrètement)
|
||||||
|
|
||||||
|
### 3.1 Principe
|
||||||
|
|
||||||
|
- Un **Server Component** est un composant **sans** `"use client"` qui peut être **`async`** et utiliser **`await fetch(...)`** directement dans le corps du composant.
|
||||||
|
- Le rendu s’exécute **sur le serveur Node** (processus Next), **une fois par requête** (sauf cache), pas dans le navigateur.
|
||||||
|
|
||||||
|
### 3.2 Ce qui reste **obligatoirement** en Client Components
|
||||||
|
|
||||||
|
Tout ce qui utilise des **hooks** React hors contexte « serveur » limité : **`useState`**, **`useEffect`**, **`useRef`** pour le menu mobile, événements clavier (**Escape**), **Swiper**, **portal** pour modales, **localStorage**, etc.
|
||||||
|
|
||||||
|
**Stratégie classique :** fichier page **sans** `"use client"` (serveur), qui **`await`** les données puis rend :
|
||||||
|
|
||||||
|
```txt
|
||||||
|
<>
|
||||||
|
<PageHeader … /> // peut être serveur
|
||||||
|
<ListeClientOuMixte … /> // sous-arbre `"use client"` uniquement où nécessaire
|
||||||
|
</>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les **carousels** (`Carousel`, `VignetteCarousel`) restent très probablement en **clients** tant qu’ils dépendent de Swiper avec effets hydratés — on leur **passe en props** les URLs / textes déjà résolus côté serveur.
|
||||||
|
|
||||||
|
### 3.3 **Layout root** (`app/layout.tsx`)
|
||||||
|
|
||||||
|
Aujourd’hui : **`"use client"`** (+ menu burger, état drawer, etc.). **Option A :** garder le layout tel quel et ne migrer **que les pages feuilles** — Next autorise une page serveur même si le layout parent est client (limites : enfants peuvent être serveur sous certaines compositions ; selon Next 13–15 il faut vérifier qu’aucune contrainte n’« impose » tout client — en pratique souvent extraction **ServerLayout** léger OU **layouts par segment** `/portfolio/layout.tsx` serveur). **Option B (plus tard) :** scinder en **layout serveur** + **header/footer client** importés dynamiquement ou composants clients enfants.
|
||||||
|
|
||||||
|
C’est le point le plus **structurant** ; il sera arbitré au moment de l’implémentation (lot par lot).
|
||||||
|
|
||||||
|
### 3.4 Appels Strapi : URL
|
||||||
|
|
||||||
|
- **Côté serveur Next**, **`getApiUrl()`** sans `window` utilise **`NEXT_PUBLIC_API_URL`** (ou défaut prod). Pour le dev **sur la même machine**, s’assurer que cette env pointe soit vers **`http://localhost:1337`**, soit vers l’URL publique **si** IIS/Strapi le servent aussi — sinon risque que le SSR appelle **`https://api.fernand…`** depuis le serveur sans route réseau correcte (**variable d’env par environnement** recommandée).
|
||||||
|
- Harmoniser avec **rewrites** existants **`/api/*` → Strapi** : possibilité d’utiliser **`fetch(new URL('/api/...', request.url ?? base))`** en SSR si vous préférez passer par Next (même origin).
|
||||||
|
|
||||||
|
### 3.5 `fetch` et cache
|
||||||
|
|
||||||
|
- **`fetch(url, { next: { revalidate: 60 } })`** : ISR-like, données rafraîchies au plus toutes les **60** secondes selon docs Next 15 (`cache` par défaut a évolué — à relire [`next` doc](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching) au moment du codage).
|
||||||
|
- Pour du **toujours frais en dev**, **`cache: 'no-store'`** peut rester le comportement désiré jusqu’à validation.
|
||||||
|
|
||||||
|
### 3.6 Mode **`dev`** (Turbopack)
|
||||||
|
|
||||||
|
- Les Server Components **fonctionnent** en `next dev` ; le **cache** `fetch` est **moins représentatif** de la prod qu’avec `next start`.
|
||||||
|
- Le **gain perçu** (HTML complet, moins de waterfall client) reste **visible** en dev sur le **réseau** / **Elements** (contenu dans le HTML source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gains réels attendus
|
||||||
|
|
||||||
|
| Dimension | Avant (client fetch) | Après (données en serveur) |
|
||||||
|
|-----------|----------------------|----------------------------|
|
||||||
|
| **Premier contenu** | Spinner / squelette jusqu’à fin de `fetch` + JSON parse côté client | Texte + structure + **images** (`next/image` avec `src` déjà dans le HTML) plus tôt |
|
||||||
|
| **Waterfall** | HTML minimal → chargement JS → `useEffect` → fetch → re-render | Un aller-retour serveur Strapi lors du SSR Next (possible parallélisation `Promise.all`) |
|
||||||
|
| **Cache Next** | Aucun data cache pour ces pages | Possibilité **revalidate** / tags plus tard |
|
||||||
|
| **SEO / réseaux lents** | Contenu peu présent sans exécuter JS | Contenu lisible avec HTML seul (**meilleur** pour indexation ; utile même si votre besoin SEO est modeste) |
|
||||||
|
| **Lighthouse LCP** | Portrait / hero souvent retardé par la chaîne client | Déjà dans le flux initial SSR (**souvent gain LCP** mesurable après stabilisation)
|
||||||
|
|
||||||
|
**Limite honnête :** **`next dev`** conserve un JS **volumeux** (Turbopack, pas minifié comme prod). Le gain **bundle** sera **maximum** après **`next build` + `next start`** (ou équivalent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ordre de migration proposé (par risque croissant)
|
||||||
|
|
||||||
|
1. **`/portfolio`** (liste seule — une liste, pattern clair).
|
||||||
|
2. **`/competences`** (liste identique).
|
||||||
|
3. **`/`** (home — `getHomepageData` + retry ; extraire logique fetch dans `lib/` serveur).
|
||||||
|
4. **`/competences/[slug]`** (deux modes : vignettes réalisations vs redirect vers container compétences — attention aux branches).
|
||||||
|
|
||||||
|
Ensuite : fiches détail **`ContentSection`** / **`fetchData`** peuvent suivre une feuille de route analogue (nombreuses pages déjà partiellement factorisées via `fetchData`).
|
||||||
|
|
||||||
|
À chaque étape : **tests manuels** des routes, erreurs réseau Strapi, **images** encore correctes (**`pickStrapiImage`** inchangé côté props).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hors scope immédiat (rappels)
|
||||||
|
|
||||||
|
- **Ne pas** renommer tous les fichiers **`.jsx`→`.tsx`** en même temps sans besoin ; migrer fichier par fichier.
|
||||||
|
- **Ne pas** activer **`compress: true`** côté Next tant que derrière **ARR/IIS** le tunnel reste incompatible (voir [`09-performances-images.md`](./09-performances-images.md) §4.6).
|
||||||
|
- **Évaluation `next build`** : à planifier après stabilisation SSR en dev ou sur une branche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Liens internes
|
||||||
|
|
||||||
|
- Audit perf images & IIS : [`09-performances-images.md`](./09-performances-images.md)
|
||||||
|
- Front Next synthèse : [`02-frontend-next.md`](./02-frontend-next.md)
|
||||||
|
- Feuille de route : [`feuille-de-route.md`](./feuille-de-route.md)
|
||||||
|
- État actuel stack : [`etat-actuel.md`](./etat-actuel.md)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# État actuel du site
|
# État actuel du site
|
||||||
|
|
||||||
**Dernière mise à jour :** 2026-04-24 (doc compétences / realisation-ia + CONFIGURATION)
|
**Dernière mise à jour :** 2026-04-28 (perf images, compression IIS/Next, plan Server Components)
|
||||||
|
|
||||||
## Ce qui est en place
|
## Ce qui est en place
|
||||||
|
|
||||||
@ -13,6 +13,7 @@
|
|||||||
- **Vault de connaissance `vault-grasbot/`** : ~46 notes Markdown, dont 2 fiches projet manuelles (GrasBot, site portfolio) et compétences IA/Web mises à jour (2026-04) — recharger l’API après déploiement si besoin : `POST /reload-vault` (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. Inclut une note `bio-fernand` courte (priority 10) dédiée aux questions biographiques et un CV complet complémentaire.
|
- **Vault de connaissance `vault-grasbot/`** : ~46 notes Markdown, dont 2 fiches projet manuelles (GrasBot, site portfolio) et compétences IA/Web mises à jour (2026-04) — recharger l’API après déploiement si besoin : `POST /reload-vault` (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. Inclut une note `bio-fernand` courte (priority 10) dédiée aux questions biographiques et un CV complet complémentaire.
|
||||||
- **Observabilité Langfuse** : instance self-hosted `langfuse.fernandgrascalvet.com`, instrumentation Python (`llm-api/observability.py`) traçant chaque requête `/ask` (retrieval / prompt_build / ollama-chat) avec session/user IDs anonymes côté front. Mode no-op automatique si les clés sont absentes. Voir [`langfuse-observability.md`](./langfuse-observability.md).
|
- **Observabilité Langfuse** : instance self-hosted `langfuse.fernandgrascalvet.com`, instrumentation Python (`llm-api/observability.py`) traçant chaque requête `/ask` (retrieval / prompt_build / ollama-chat) avec session/user IDs anonymes côté front. Mode no-op automatique si les clés sont absentes. Voir [`langfuse-observability.md`](./langfuse-observability.md).
|
||||||
- **Scripts** d'extraction et de doc dans `strapi_extraction/`.
|
- **Scripts** d'extraction et de doc dans `strapi_extraction/`.
|
||||||
|
- **Performances images (front)** : utilitaire **`pickStrapiImage`**, variantes Strapi, **`next/image`** + `remotePatterns`, portrait **`priority`**, **`preconnect`** API dans `layout.tsx`. **`compress: false`** dans `next.config.ts` (obligatoire avec le reverse proxy IIS actuel — **`compress: true`** provoquait des **500** publics). Détail : [`09-performances-images.md`](./09-performances-images.md). Prochaine étape planifiée (sans implémentation engageant pour l’instant) : **Server Components** — [`10-plan-server-components.md`](./10-plan-server-components.md).
|
||||||
- Documentation opérationnelle : [`CONFIGURATION_SITE.md`](../CONFIGURATION_SITE.md) à la racine du dépôt (incl. ordre des compétences et routes dédiées, renvoi vers [02-frontend-next.md](./02-frontend-next.md)).
|
- Documentation opérationnelle : [`CONFIGURATION_SITE.md`](../CONFIGURATION_SITE.md) à la racine du dépôt (incl. ordre des compétences et routes dédiées, renvoi vers [02-frontend-next.md](./02-frontend-next.md)).
|
||||||
- **Captures d'écran** de référence (WebP) : [captures/](./captures/) — voir [INDEX.md](./captures/INDEX.md).
|
- **Captures d'écran** de référence (WebP) : [captures/](./captures/) — voir [INDEX.md](./captures/INDEX.md).
|
||||||
- **Décision produit** : une **rubrique homelab / serveur** (souvent évoquée en « phase 3 ») n’est **pas retenue** — pas d’évolution planifiée sur ce thème ; le parcours public reste portfolio, compétences (dont IA + réalisations) et contact.
|
- **Décision produit** : une **rubrique homelab / serveur** (souvent évoquée en « phase 3 ») n’est **pas retenue** — pas d’évolution planifiée sur ce thème ; le parcours public reste portfolio, compétences (dont IA + réalisations) et contact.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Feuille de route
|
# Feuille de route
|
||||||
|
|
||||||
**Dernière mise à jour :** 2026-04-24
|
**Dernière mise à jour :** 2026-04-28
|
||||||
|
|
||||||
Document vivant : ajuster les statuts et dates au fil du travail.
|
Document vivant : ajuster les statuts et dates au fil du travail.
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
|||||||
| 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-7 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences + fiches détail & GrasBot) faites le 2026-04-22. Reste l'étape 8 (contact + footer éditorial). |
|
| 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-7 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences + fiches détail & GrasBot) faites le 2026-04-22. Reste l'étape 8 (contact + footer éditorial). |
|
||||||
| R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` |
|
| 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 |
|
| 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 |
|
| R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Lié au plan [`10-plan-server-components.md`](./10-plan-server-components.md) — extraction header/footer client possible |
|
||||||
| R5 | Chatbot GrasBot — retrieval local (Qwen3 + vault Obsidian) | v3 en place | Pipeline **graph + BM25** (`llm-api/search.py`), plus de RAG vectoriel ni de dépendance ChromaDB. Vault `vault-grasbot/` enrichi automatiquement (aliases/answers/priority). Lecture directe depuis `build-vault.py`, plus d'étape d'indexation. Doc : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md). Reste : fix `clean-api-data.js` (homepages + glossaires), affichage sources côté front, badge `grounded`, historique conversationnel, streaming. |
|
| R5 | Chatbot GrasBot — retrieval local (Qwen3 + vault Obsidian) | v3 en place | Pipeline **graph + BM25** (`llm-api/search.py`), plus de RAG vectoriel ni de dépendance ChromaDB. Vault `vault-grasbot/` enrichi automatiquement (aliases/answers/priority). Lecture directe depuis `build-vault.py`, plus d'étape d'indexation. Doc : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md). Reste : fix `clean-api-data.js` (homepages + glossaires), affichage sources côté front, badge `grounded`, historique conversationnel, streaming. |
|
||||||
|
|
||||||
## Moyen terme
|
## Moyen terme
|
||||||
@ -20,7 +20,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
|||||||
|----|--------|--------|--------|
|
|----|--------|--------|--------|
|
||||||
| M1 | Tests (e2e ou smoke sur routes critiques) | À faire | |
|
| M1 | Tests (e2e ou smoke sur routes critiques) | À faire | |
|
||||||
| M2 | Accessibilité (navigation, contrastes, focus) | À faire | |
|
| M2 | Accessibilité (navigation, contrastes, focus) | À faire | |
|
||||||
| M3 | Performance (images Next/Image, bundle) | À faire | |
|
| M3 | Performance (images Next/Image, bundle, SSR données) | En cours | Images + `pickStrapiImage` + preconnect : [`09-performances-images.md`](./09-performances-images.md). **`compress`** : maintenu à **`false`** (IIS). **Prochaine étape** : Server Components — plan [`10-plan-server-components.md`](./10-plan-server-components.md) (pas d’implémentation sans go utilisateur). |
|
||||||
|
|
||||||
## Long terme / idées
|
## Long terme / idées
|
||||||
|
|
||||||
@ -59,3 +59,5 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
|||||||
| 2026-04-23 | **GrasBot — tuning pipeline LLM + anti-hallucinations**. Audit des premières traces Langfuse : questions biographiques hallucinées (âge erroné, statut inventé), réponses longues tronquées. Quatre ajustements : (1) `llm-api/search.py` · `generate()` — `num_ctx=8192` explicite (stoppe la troncature silencieuse du prompt par le défaut Ollama 2048/4096 quand plusieurs notes entières sont injectées), `num_predict` 512 → 1024 (réponses longues complètes), `think: false` top-level (désactive le *thinking mode* de qwen3 qui consommait du budget de sortie). (2) `llm-api/search.py` · `build_prompt()` — troncature conditionnelle des sources rank 2+ via `_truncate_body()` + nouvelles variables `SEARCH_SECONDARY_MAX_CHARS` (1500) / `SEARCH_SECONDARY_KEEP_RATIO` (0.8). Aucune source n'est supprimée, seules celles dont le score est < 0.8 × score(#1) ET dont le body dépasse 1500 chars sont résumées. Loggé dans `prompt_build.metadata.truncation`. (3) Vault — nouvelle note `vault-grasbot/30-Parcours/bio-fernand.md` courte et factuelle (priority 10, aliases biographiques courts), canonique pour les questions du type *« qui est Fernand »*. Renvoie vers le CV complet pour le détail. Correction incohérence d'âge dans le CV (46 → 47 ans dans la section Présentation) qui alimentait les hallucinations. (4) `SYSTEM_PROMPT` — nouveau bloc *Règles de fidélité aux sources* : priorité `type=parcours` pour questions bio, interdiction d'inventer des faits factuels, gestion explicite des contradictions, signalement des notes tronquées. **Bascule Langfuse v4 → v3 dans `requirements.txt`** (`langfuse>=3.0,<4`) : le SDK v4 a supprimé `start_as_current_span`, la v3 reste compatible avec l'instrumentation existante. Dépendances Python ajoutées : `langfuse`, `python-dotenv`. Secrets Langfuse déplacés de `.env.local` Next vers `llm-api/.env` (non committé). Doc mise à jour : [`langfuse-observability.md`](./langfuse-observability.md) (nouvelle section *Tuning du pipeline — 2026-04-23*), `CONFIGURATION_SITE.md` (endpoints `/health` + `/reload-vault`), `etat-actuel.md` (42 notes + mention Langfuse). |
|
| 2026-04-23 | **GrasBot — tuning pipeline LLM + anti-hallucinations**. Audit des premières traces Langfuse : questions biographiques hallucinées (âge erroné, statut inventé), réponses longues tronquées. Quatre ajustements : (1) `llm-api/search.py` · `generate()` — `num_ctx=8192` explicite (stoppe la troncature silencieuse du prompt par le défaut Ollama 2048/4096 quand plusieurs notes entières sont injectées), `num_predict` 512 → 1024 (réponses longues complètes), `think: false` top-level (désactive le *thinking mode* de qwen3 qui consommait du budget de sortie). (2) `llm-api/search.py` · `build_prompt()` — troncature conditionnelle des sources rank 2+ via `_truncate_body()` + nouvelles variables `SEARCH_SECONDARY_MAX_CHARS` (1500) / `SEARCH_SECONDARY_KEEP_RATIO` (0.8). Aucune source n'est supprimée, seules celles dont le score est < 0.8 × score(#1) ET dont le body dépasse 1500 chars sont résumées. Loggé dans `prompt_build.metadata.truncation`. (3) Vault — nouvelle note `vault-grasbot/30-Parcours/bio-fernand.md` courte et factuelle (priority 10, aliases biographiques courts), canonique pour les questions du type *« qui est Fernand »*. Renvoie vers le CV complet pour le détail. Correction incohérence d'âge dans le CV (46 → 47 ans dans la section Présentation) qui alimentait les hallucinations. (4) `SYSTEM_PROMPT` — nouveau bloc *Règles de fidélité aux sources* : priorité `type=parcours` pour questions bio, interdiction d'inventer des faits factuels, gestion explicite des contradictions, signalement des notes tronquées. **Bascule Langfuse v4 → v3 dans `requirements.txt`** (`langfuse>=3.0,<4`) : le SDK v4 a supprimé `start_as_current_span`, la v3 reste compatible avec l'instrumentation existante. Dépendances Python ajoutées : `langfuse`, `python-dotenv`. Secrets Langfuse déplacés de `.env.local` Next vers `llm-api/.env` (non committé). Doc mise à jour : [`langfuse-observability.md`](./langfuse-observability.md) (nouvelle section *Tuning du pipeline — 2026-04-23*), `CONFIGURATION_SITE.md` (endpoints `/health` + `/reload-vault`), `etat-actuel.md` (42 notes + mention Langfuse). |
|
||||||
| 2026-04-22 | **GrasBot v3 — bascule RAG vectoriel → retrieval graph + BM25**. Essais d'installation Windows bloqués par `chroma-hnswlib` (compilation C++ requise) et freezes RDP à chaque chargement de `qwen3:8b` + `nomic-embed-text` simultanément. Arbitrage : pour un vault de 40 notes, la RAG vectorielle sur-dimensionne ; on exploite directement la structure Obsidian (frontmatter, wikilinks, MOCs). **Nouveau pipeline** dans `llm-api/search.py` (scoring multi-signaux : aliases / titre-slug / answers / domains / tags / BM25 ; expansion par graphe via `linked`/`related`/wikilinks ; tokenizer FR avec normalisations `c++` → `cpp`, split `-`/`_`). **Déterministe, traçable (champ `reasons` dans les sources), 50 ms de retrieval**. Scoring calibré sur 12 cas (IA, push-swap, LLMs pluriel, hors-sujet clafoutis → `(aucun)`, etc.). **Dépendances allégées** : fini `chromadb`, `chroma-hnswlib`, `nomic-embed-text`. `requirements.txt` = fastapi + uvicorn + requests + pyyaml uniquement. Fichiers supprimés : `llm-api/rag.py`, `llm-api/index_vault.py`, `chroma-index/` (marqué pour suppression, verrouillé par Cursor au moment du cleanup — sera supprimé au reboot). **Vault enrichi** : `build-vault.py` étendu pour générer automatiquement `aliases` (à partir du slug/titre + `DOMAIN_ALIASES`), `answers` (questions-types adaptées au type de note), `priority` (heuristique CV=10, MOCs=7, compétences=7, projets=5). Note CV curatée (`source: manual`) enrichie manuellement avec 12 aliases et 7 answers. Nouvelle `vault-grasbot/TAXONOMIE.md` qui documente le vocabulaire contrôlé. Réécriture de `vault-grasbot/50-Technique/grasbot-rag.md` → `grasbot-retrieval.md` (nouveau pipeline), + `architecture-site.md` + `vault-structure.md` + `MOC-Technique.md`. Nouveau endpoint `POST /reload-vault` pour recharger sans redémarrer uvicorn. Documentation interne refaite : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md), [`06-strapi-extraction.md`](./06-strapi-extraction.md), nouveau [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) (remplace `08-vault-obsidian-rag.md`). |
|
| 2026-04-22 | **GrasBot v3 — bascule RAG vectoriel → retrieval graph + BM25**. Essais d'installation Windows bloqués par `chroma-hnswlib` (compilation C++ requise) et freezes RDP à chaque chargement de `qwen3:8b` + `nomic-embed-text` simultanément. Arbitrage : pour un vault de 40 notes, la RAG vectorielle sur-dimensionne ; on exploite directement la structure Obsidian (frontmatter, wikilinks, MOCs). **Nouveau pipeline** dans `llm-api/search.py` (scoring multi-signaux : aliases / titre-slug / answers / domains / tags / BM25 ; expansion par graphe via `linked`/`related`/wikilinks ; tokenizer FR avec normalisations `c++` → `cpp`, split `-`/`_`). **Déterministe, traçable (champ `reasons` dans les sources), 50 ms de retrieval**. Scoring calibré sur 12 cas (IA, push-swap, LLMs pluriel, hors-sujet clafoutis → `(aucun)`, etc.). **Dépendances allégées** : fini `chromadb`, `chroma-hnswlib`, `nomic-embed-text`. `requirements.txt` = fastapi + uvicorn + requests + pyyaml uniquement. Fichiers supprimés : `llm-api/rag.py`, `llm-api/index_vault.py`, `chroma-index/` (marqué pour suppression, verrouillé par Cursor au moment du cleanup — sera supprimé au reboot). **Vault enrichi** : `build-vault.py` étendu pour générer automatiquement `aliases` (à partir du slug/titre + `DOMAIN_ALIASES`), `answers` (questions-types adaptées au type de note), `priority` (heuristique CV=10, MOCs=7, compétences=7, projets=5). Note CV curatée (`source: manual`) enrichie manuellement avec 12 aliases et 7 answers. Nouvelle `vault-grasbot/TAXONOMIE.md` qui documente le vocabulaire contrôlé. Réécriture de `vault-grasbot/50-Technique/grasbot-rag.md` → `grasbot-retrieval.md` (nouveau pipeline), + `architecture-site.md` + `vault-structure.md` + `MOC-Technique.md`. Nouveau endpoint `POST /reload-vault` pour recharger sans redémarrer uvicorn. Documentation interne refaite : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md), [`06-strapi-extraction.md`](./06-strapi-extraction.md), nouveau [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) (remplace `08-vault-obsidian-rag.md`). |
|
||||||
| 2026-04-24 | **Doc + configuration** : routes `/competences/[slug]/[realisation]`, entité Strapi `realisation-ia`, tri `order` et comportement vignettes/richtext documentés dans `02-frontend-next.md`, `04-api-llm-et-chatbot.md` (parcours public), `etat-actuel.md` ; `CONFIGURATION_SITE.md` : section *Contenu : compétences, réalisations IA et ordre d’affichage*. Décision : **pas de « phase 3 » homelab** sur le site (consignée dans l’état actuel). |
|
| 2026-04-24 | **Doc + configuration** : routes `/competences/[slug]/[realisation]`, entité Strapi `realisation-ia`, tri `order` et comportement vignettes/richtext documentés dans `02-frontend-next.md`, `04-api-llm-et-chatbot.md` (parcours public), `etat-actuel.md` ; `CONFIGURATION_SITE.md` : section *Contenu : compétences, réalisations IA et ordre d’affichage*. Décision : **pas de « phase 3 » homelab** sur le site (consignée dans l’état actuel). |
|
||||||
|
| 2026-04-28 | **Perf images Next** : lots A/B/C + socle F documentés dans [`09-performances-images.md`](./09-performances-images.md) — `pickStrapiImage`, `next/image` (listes, carousels, hero), `remotePatterns`, preconnect API. Lot B (`compress: true`) **annulé** : conflit IIS reverse proxy (500). |
|
||||||
|
| 2026-04-28 | **Plan Server Components** : nouveau doc [`10-plan-server-components.md`](./10-plan-server-components.md) (périmètre, gains, ordre de migration, contraintes `dev` / layout). **Aucun code** modifié dans ce lot. |
|
||||||
|
|||||||
@ -22,7 +22,26 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
domains: ["localhost", "api.fernandgrascalvet.com"],
|
formats: ["image/avif", "image/webp"],
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "api.fernandgrascalvet.com",
|
||||||
|
pathname: "/uploads/**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
port: "1337",
|
||||||
|
pathname: "/uploads/**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: "1337",
|
||||||
|
pathname: "/uploads/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,5 +22,6 @@
|
|||||||
</rewrite>
|
</rewrite>
|
||||||
<directoryBrowse enabled="true" />
|
<directoryBrowse enabled="true" />
|
||||||
<caching enableKernelCache="true" />
|
<caching enableKernelCache="true" />
|
||||||
|
<urlCompression doStaticCompression="true" doDynamicCompression="false" />
|
||||||
</system.webServer>
|
</system.webServer>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user